- Delete old Vite+Svelte frontend - Initialize new SvelteKit project with TypeScript - Configure Tailwind CSS v4 + DaisyUI - Implement JWT authentication with auto-refresh - Create login page with form validation (Zod) - Add protected route guards - Update Docker configuration for single-stage build - Add E2E tests with Playwright (6/11 passing) - Fix Svelte 5 reactivity with $state() runes Known issues: - 5 E2E tests failing (timing/async issues) - Token refresh implementation needs debugging - Validation error display timing
397 lines
17 KiB
JavaScript
397 lines
17 KiB
JavaScript
import { createRule } from '../utils/index.js';
|
|
import { getTypeScriptTools, isAnyType } from '../utils/ts-utils/index.js';
|
|
import { findVariable } from '../utils/ast-utils.js';
|
|
import { toRegExp } from '../utils/regexp.js';
|
|
import { normalize } from 'path';
|
|
let isRemovedWarningShown = false;
|
|
export default createRule('no-unused-props', {
|
|
meta: {
|
|
docs: {
|
|
description: 'Warns about defined Props properties that are unused',
|
|
category: 'Best Practices',
|
|
recommended: true
|
|
},
|
|
schema: [
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
checkImportedTypes: {
|
|
type: 'boolean',
|
|
default: false
|
|
},
|
|
ignoreTypePatterns: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'string'
|
|
},
|
|
default: []
|
|
},
|
|
ignorePropertyPatterns: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'string'
|
|
},
|
|
default: []
|
|
},
|
|
allowUnusedNestedProperties: {
|
|
type: 'boolean',
|
|
default: false
|
|
}
|
|
},
|
|
additionalProperties: false
|
|
}
|
|
],
|
|
messages: {
|
|
unusedProp: "'{{name}}' is an unused Props property.",
|
|
unusedNestedProp: "'{{name}}' in '{{parent}}' is an unused property.",
|
|
unusedIndexSignature: 'Index signature is unused. Consider using rest operator (...) to capture remaining properties.'
|
|
},
|
|
type: 'suggestion',
|
|
conditions: [
|
|
{
|
|
svelteVersions: ['5'],
|
|
runes: [true, 'undetermined']
|
|
}
|
|
]
|
|
},
|
|
create(context) {
|
|
const fileName = context.filename;
|
|
const tools = getTypeScriptTools(context);
|
|
if (!tools) {
|
|
return {};
|
|
}
|
|
const typeChecker = tools.service.program.getTypeChecker();
|
|
if (!typeChecker) {
|
|
return {};
|
|
}
|
|
const options = context.options[0] ?? {};
|
|
// TODO: Remove in v4
|
|
// MEMO: `ignorePatterns` was a property that only existed from v3.2.0 to v3.2.2.
|
|
// From v3.3.0, it was replaced with `ignorePropertyPatterns` and `ignoreTypePatterns`.
|
|
if (options.ignorePatterns != null && !isRemovedWarningShown) {
|
|
console.warn('eslint-plugin-svelte: The `ignorePatterns` option in the `no-unused-props` rule has been removed. Please use `ignorePropertyPatterns` or/and `ignoreTypePatterns` instead.');
|
|
isRemovedWarningShown = true;
|
|
}
|
|
const checkImportedTypes = options.checkImportedTypes ?? false;
|
|
const ignoreTypePatterns = (options.ignoreTypePatterns ?? []).map((p) => {
|
|
if (typeof p === 'string') {
|
|
return toRegExp(p);
|
|
}
|
|
return p;
|
|
});
|
|
const ignorePropertyPatterns = (options.ignorePropertyPatterns ?? []).map((p) => {
|
|
if (typeof p === 'string') {
|
|
return toRegExp(p);
|
|
}
|
|
return p;
|
|
});
|
|
function shouldIgnoreProperty(name) {
|
|
return ignorePropertyPatterns.some((pattern) => pattern.test(name));
|
|
}
|
|
function shouldIgnoreType(type) {
|
|
function isMatched(name) {
|
|
return ignoreTypePatterns.some((pattern) => pattern.test(name));
|
|
}
|
|
const typeStr = typeChecker.typeToString(type);
|
|
const symbol = type.getSymbol();
|
|
const symbolName = symbol?.getName();
|
|
return isMatched(typeStr) || (symbolName ? isMatched(symbolName) : false);
|
|
}
|
|
function isInternalProperty(symbol) {
|
|
const declarations = symbol.getDeclarations();
|
|
if (!declarations || declarations.length === 0)
|
|
return false;
|
|
// TypeScript declaration file name is normalized to support Windows style paths
|
|
return declarations.every((decl) => normalize(decl.getSourceFile().fileName) === fileName);
|
|
}
|
|
/**
|
|
* Extracts property paths from member expressions.
|
|
*/
|
|
function getPropertyPath(node) {
|
|
const paths = [];
|
|
let isSpread = false;
|
|
let currentNode = node;
|
|
let parentNode = currentNode.parent ?? null;
|
|
while (parentNode) {
|
|
if (parentNode.type === 'MemberExpression' && parentNode.object === currentNode) {
|
|
const property = parentNode.property;
|
|
if (property.type === 'Identifier') {
|
|
paths.push(property.name);
|
|
}
|
|
else if (property.type === 'Literal' && typeof property.value === 'string') {
|
|
paths.push(property.value);
|
|
}
|
|
}
|
|
else {
|
|
if (parentNode.type === 'SpreadElement' || parentNode.type === 'SvelteSpreadAttribute') {
|
|
isSpread = true;
|
|
}
|
|
break;
|
|
}
|
|
currentNode = parentNode;
|
|
parentNode = currentNode.parent ?? null;
|
|
}
|
|
return { paths, isSpread };
|
|
}
|
|
/**
|
|
* Finds all property access paths for a given variable.
|
|
*/
|
|
function getUsedNestedPropertyPathsArray(node) {
|
|
const variable = findVariable(context, node);
|
|
if (!variable)
|
|
return { paths: [], spreadPaths: [] };
|
|
const pathsArray = [];
|
|
const spreadPathsArray = [];
|
|
for (const reference of variable.references) {
|
|
if ('identifier' in reference &&
|
|
reference.identifier.type === 'Identifier' &&
|
|
(reference.identifier.range[0] !== node.range[0] ||
|
|
reference.identifier.range[1] !== node.range[1])) {
|
|
const { paths, isSpread } = getPropertyPath(reference.identifier);
|
|
if (isSpread) {
|
|
spreadPathsArray.push(paths);
|
|
}
|
|
else {
|
|
pathsArray.push(paths);
|
|
}
|
|
}
|
|
}
|
|
return { paths: pathsArray, spreadPaths: spreadPathsArray };
|
|
}
|
|
/**
|
|
* Checks if a property is from TypeScript's built-in type definitions.
|
|
* These properties should be ignored as they are not user-defined props.
|
|
*/
|
|
function isBuiltInProperty(prop) {
|
|
const declarations = prop.getDeclarations();
|
|
if (!declarations || declarations.length === 0)
|
|
return false;
|
|
const declaration = declarations[0];
|
|
const sourceFile = declaration.getSourceFile();
|
|
if (!sourceFile)
|
|
return false;
|
|
return sourceFile.fileName.includes('node_modules/typescript/lib/');
|
|
}
|
|
function getUsedPropertyNamesFromPattern(pattern) {
|
|
const usedProps = new Set();
|
|
for (const prop of pattern.properties) {
|
|
if (prop.type === 'Property') {
|
|
if (prop.key.type === 'Identifier') {
|
|
usedProps.add({ originalName: prop.key.name, aliasName: prop.key.name });
|
|
}
|
|
else if (prop.key.type === 'Literal' &&
|
|
typeof prop.key.value === 'string' &&
|
|
prop.value.type === 'Identifier') {
|
|
usedProps.add({ originalName: prop.key.value, aliasName: prop.value.name });
|
|
}
|
|
}
|
|
else if (prop.type === 'RestElement') {
|
|
// If there's a rest element, all properties are potentially used
|
|
return new Set();
|
|
}
|
|
}
|
|
return usedProps;
|
|
}
|
|
/**
|
|
* Check if the type is a class type (has constructor or prototype)
|
|
*/
|
|
function isClassType(type) {
|
|
if (!type)
|
|
return false;
|
|
// Check if it's a class instance type
|
|
if (type.isClass())
|
|
return true;
|
|
// Check for constructor signatures
|
|
const constructorType = type.getConstructSignatures();
|
|
if (constructorType.length > 0)
|
|
return true;
|
|
// Check if it has a prototype property
|
|
const symbol = type.getSymbol();
|
|
if (symbol?.members?.has('prototype'))
|
|
return true;
|
|
return false;
|
|
}
|
|
/**
|
|
* Recursively checks for unused properties in a type.
|
|
*/
|
|
function checkUnusedProperties({ propsType, usedPropertyPaths, usedSpreadPropertyPaths, declaredPropertyNames, reportNode, parentPath, checkedPropsTypes, reportedPropertyPaths }) {
|
|
// Skip checking if the type itself is a class
|
|
if (isClassType(propsType))
|
|
return;
|
|
const typeStr = typeChecker.typeToString(propsType);
|
|
if (checkedPropsTypes.has(typeStr))
|
|
return;
|
|
checkedPropsTypes.add(typeStr);
|
|
if (shouldIgnoreType(propsType))
|
|
return;
|
|
const properties = typeChecker.getPropertiesOfType(propsType);
|
|
const propsBaseTypes = propsType.getBaseTypes();
|
|
if (!properties.length && (!propsBaseTypes || propsBaseTypes.length === 0)) {
|
|
return;
|
|
}
|
|
if (propsBaseTypes) {
|
|
for (const propsBaseType of propsBaseTypes) {
|
|
checkUnusedProperties({
|
|
propsType: propsBaseType,
|
|
usedPropertyPaths,
|
|
usedSpreadPropertyPaths,
|
|
declaredPropertyNames,
|
|
reportNode,
|
|
parentPath,
|
|
checkedPropsTypes,
|
|
reportedPropertyPaths
|
|
});
|
|
}
|
|
}
|
|
for (const prop of properties) {
|
|
if (isBuiltInProperty(prop))
|
|
continue;
|
|
if (!checkImportedTypes && !isInternalProperty(prop))
|
|
continue;
|
|
const propName = prop.getName();
|
|
if (shouldIgnoreProperty(propName))
|
|
continue;
|
|
const currentPath = [...parentPath, propName];
|
|
const currentPathStr = currentPath.join('.');
|
|
if (reportedPropertyPaths.has(currentPathStr))
|
|
continue;
|
|
const propType = typeChecker.getTypeOfSymbol(prop);
|
|
const isUsedThisInPath = usedPropertyPaths.includes(currentPathStr) ||
|
|
usedSpreadPropertyPaths.some((path) => {
|
|
return path === '' || path === currentPathStr || path.startsWith(`${currentPathStr}.`);
|
|
});
|
|
const isUsedInPath = usedPropertyPaths.some((path) => {
|
|
return path.startsWith(`${currentPathStr}.`);
|
|
});
|
|
if (isUsedThisInPath && !isUsedInPath) {
|
|
continue;
|
|
}
|
|
const isUsedInProps = Array.from(declaredPropertyNames).some((p) => {
|
|
return p.originalName === propName;
|
|
});
|
|
if (!isUsedInPath && !isUsedInProps) {
|
|
reportedPropertyPaths.add(currentPathStr);
|
|
context.report({
|
|
node: reportNode,
|
|
messageId: parentPath.length ? 'unusedNestedProp' : 'unusedProp',
|
|
data: {
|
|
name: propName,
|
|
parent: parentPath.join('.')
|
|
}
|
|
});
|
|
continue;
|
|
}
|
|
const isUsedNested = usedPropertyPaths.some((path) => {
|
|
return path.startsWith(`${currentPathStr}.`);
|
|
});
|
|
if (isUsedNested || isUsedInProps) {
|
|
checkUnusedProperties({
|
|
propsType: propType,
|
|
usedPropertyPaths,
|
|
usedSpreadPropertyPaths,
|
|
declaredPropertyNames,
|
|
reportNode,
|
|
parentPath: currentPath,
|
|
checkedPropsTypes,
|
|
reportedPropertyPaths
|
|
});
|
|
}
|
|
}
|
|
// Check for unused index signatures only at the root level
|
|
if (parentPath.length === 0) {
|
|
const indexType = propsType.getStringIndexType();
|
|
const numberIndexType = propsType.getNumberIndexType();
|
|
const hasIndexSignature = Boolean(indexType && !isAnyType(indexType, tools.ts)) ||
|
|
Boolean(numberIndexType && !isAnyType(numberIndexType, tools.ts));
|
|
if (hasIndexSignature && !hasRestElement(declaredPropertyNames)) {
|
|
context.report({
|
|
node: reportNode,
|
|
messageId: 'unusedIndexSignature'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Returns true if the destructuring pattern includes a rest element,
|
|
* which means all remaining properties are potentially used.
|
|
*/
|
|
function hasRestElement(declaredPropertyNames) {
|
|
return declaredPropertyNames.size === 0;
|
|
}
|
|
function normalizeUsedPaths(paths, allowUnusedNestedProperties) {
|
|
const normalized = [];
|
|
for (const path of paths.sort((a, b) => a.length - b.length)) {
|
|
if (normalized.some((p) => p.every((part, idx) => part === path[idx]))) {
|
|
continue;
|
|
}
|
|
normalized.push(path);
|
|
}
|
|
return normalized.map((path) => {
|
|
// If we allow unused nested properties, only return first level properties
|
|
if (allowUnusedNestedProperties)
|
|
return [path[0]];
|
|
return path;
|
|
});
|
|
}
|
|
return {
|
|
'VariableDeclaration > VariableDeclarator': (node) => {
|
|
// Only check $props declarations
|
|
if (node.init?.type !== 'CallExpression' ||
|
|
node.init.callee.type !== 'Identifier' ||
|
|
node.init.callee.name !== '$props') {
|
|
return;
|
|
}
|
|
const tsNode = tools.service.esTreeNodeToTSNodeMap.get(node);
|
|
if (!tsNode || !tsNode.type)
|
|
return;
|
|
const propsType = typeChecker.getTypeFromTypeNode(tsNode.type);
|
|
const usedPropertyPathsArray = [];
|
|
const usedSpreadPropertyPathsArray = [];
|
|
let declaredPropertyNames = new Set();
|
|
if (node.id.type === 'ObjectPattern') {
|
|
declaredPropertyNames = getUsedPropertyNamesFromPattern(node.id);
|
|
if (declaredPropertyNames.size === 0)
|
|
return;
|
|
const identifiers = [];
|
|
for (const p of node.id.properties) {
|
|
if (p.type !== 'Property') {
|
|
continue;
|
|
}
|
|
if (p.value.type === 'Identifier') {
|
|
identifiers.push(p.value);
|
|
}
|
|
else if (p.value.type === 'AssignmentPattern' && p.value.left.type === 'Identifier') {
|
|
identifiers.push(p.value.left);
|
|
}
|
|
}
|
|
for (const identifier of identifiers) {
|
|
const { paths, spreadPaths } = getUsedNestedPropertyPathsArray(identifier);
|
|
usedPropertyPathsArray.push(...paths.map((path) => [identifier.name, ...path]));
|
|
usedSpreadPropertyPathsArray.push(...spreadPaths.map((path) => [identifier.name, ...path]));
|
|
}
|
|
}
|
|
else if (node.id.type === 'Identifier') {
|
|
const { paths, spreadPaths } = getUsedNestedPropertyPathsArray(node.id);
|
|
usedPropertyPathsArray.push(...paths);
|
|
usedSpreadPropertyPathsArray.push(...spreadPaths);
|
|
}
|
|
function runNormalizeUsedPaths(paths) {
|
|
return normalizeUsedPaths(paths, options.allowUnusedNestedProperties).map((pathArray) => {
|
|
return pathArray.join('.');
|
|
});
|
|
}
|
|
checkUnusedProperties({
|
|
propsType,
|
|
usedPropertyPaths: runNormalizeUsedPaths(usedPropertyPathsArray),
|
|
usedSpreadPropertyPaths: runNormalizeUsedPaths(usedSpreadPropertyPathsArray),
|
|
declaredPropertyNames,
|
|
reportNode: node.id,
|
|
parentPath: [],
|
|
checkedPropsTypes: new Set(),
|
|
reportedPropertyPaths: new Set()
|
|
});
|
|
}
|
|
};
|
|
}
|
|
});
|