Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions lint/missing-dependency-rule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { defineRule } from '@tsslint/config';
import * as path from 'node:path';

export default defineRule(({ typescript: ts, file, program, report }) => {
const { noEmit } = program.getCompilerOptions();
if (noEmit) {
return;
}
const packageJsonPath = ts.findConfigFile(file.fileName, ts.sys.fileExists, 'package.json');
if (!packageJsonPath) {
return;
}
const packageJson = JSON.parse(ts.sys.readFile(packageJsonPath) ?? '');
if (packageJson.private) {
return;
}
const parentPackageJsonPath = ts.findConfigFile(
path.dirname(path.dirname(packageJsonPath)),
ts.sys.fileExists,
'package.json',
);
const parentPackageJson = !!parentPackageJsonPath && parentPackageJsonPath !== packageJsonPath
? JSON.parse(ts.sys.readFile(parentPackageJsonPath) ?? '')
: {};
ts.forEachChild(file, function visit(node) {
if (
ts.isImportDeclaration(node)
&& !node.importClause?.isTypeOnly
&& ts.isStringLiteral(node.moduleSpecifier)
&& !node.moduleSpecifier.text.startsWith('./')
&& !node.moduleSpecifier.text.startsWith('../')
) {
let moduleName = node.moduleSpecifier.text.split('/')[0]!;
if (moduleName.startsWith('@')) {
moduleName += '/' + node.moduleSpecifier.text.split('/')[1];
}
if (
(
packageJson.devDependencies?.[moduleName]
|| parentPackageJson.dependencies?.[moduleName]
|| parentPackageJson.devDependencies?.[moduleName]
|| parentPackageJson.peerDependencies?.[moduleName]
)
&& !packageJson.dependencies?.[moduleName]
&& !packageJson.peerDependencies?.[moduleName]
) {
report(
`Module '${moduleName}' should be in the dependencies.`,
node.getStart(file),
node.getEnd(),
);
}
}
ts.forEachChild(node, visit);
});
});
49 changes: 49 additions & 0 deletions lint/type-imports-rule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { defineRule } from '@tsslint/config';

export default defineRule(({ typescript: ts, file, report }) => {
ts.forEachChild(file, function visit(node) {
if (
ts.isImportDeclaration(node)
&& node.importClause
&& node.importClause.namedBindings
&& node.importClause.phaseModifier !== ts.SyntaxKind.TypeKeyword
&& !node.importClause.name
&& !ts.isNamespaceImport(node.importClause.namedBindings)
&& node.importClause.namedBindings.elements.every(e => e.isTypeOnly)
) {
const typeElements = node.importClause.namedBindings.elements;
report(
'This import statement should use type-only import.',
node.getStart(file),
node.getEnd(),
).withFix(
'Replace inline type imports with a type-only import.',
() => [
{
fileName: file.fileName,
textChanges: [
...typeElements.map(element => {
const token = element.getFirstToken(file)!;
return {
newText: '',
span: {
start: token.getStart(file),
length: element.name.getStart(file) - token.getStart(file),
},
};
}),
{
newText: 'type ',
span: {
start: node.importClause!.getStart(file),
length: 0,
},
},
],
},
],
);
}
ts.forEachChild(node, visit);
});
});
172 changes: 172 additions & 0 deletions lint/typescript-services-types-rule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { defineRule } from '@tsslint/config';
import type * as ts from 'typescript';

/*
* services types list: https://github.com/microsoft/TypeScript/blob/38d95c8001300f525fd601dd0ce6d0ff5f12baee/src/services/types.ts
* commit: 96acaa52902feb1320e1d8ec8936b8669cca447d (2025-09-25)
*/
const SERVICES_TYPES: Record<string, string[]> = {
Node: [
'getSourceFile',
'getChildCount',
'getChildAt',
'getChildren',
'getStart',
'getFullStart',
'getEnd',
'getWidth',
'getFullWidth',
'getLeadingTriviaWidth',
'getFullText',
'getText',
'getFirstToken',
'getLastToken',
'forEachChild',
],
Identifier: [
'text',
],
PrivateIdentifier: [
'text',
],
Symbol: [
'name',
'getFlags',
'getEscapedName',
'getName',
'getDeclarations',
'getDocumentationComment',
'getContextualDocumentationComment',
'getJsDocTags',
'getContextualJsDocTags',
],
Type: [
'getFlags',
'getSymbol',
'getProperties',
'getProperty',
'getApparentProperties',
'getCallSignatures',
'getConstructSignatures',
'getStringIndexType',
'getNumberIndexType',
'getBaseTypes',
'getNonNullableType',
'getConstraint',
'getDefault',
'isUnion',
'isIntersection',
'isUnionOrIntersection',
'isLiteral',
'isStringLiteral',
'isNumberLiteral',
'isTypeParameter',
'isClassOrInterface',
'isClass',
'isIndexType',
],
TypeReference: [
'typeArguments',
],
Signature: [
'getDeclaration',
'getTypeParameters',
'getParameters',
'getTypeParameterAtPosition',
'getReturnType',
'getDocumentationComment',
'getJsDocTags',
],
SourceFile: [
'version',
'scriptSnapshot',
'nameTable',
'getNamedDeclarations',
'getLineAndCharacterOfPosition',
'getLineEndOfPosition',
'getLineStarts',
'getPositionOfLineAndCharacter',
'update',
'sourceMapper',
],
SourceFileLike: [
'getLineAndCharacterOfPosition',
],
SourceMapSource: [
'getLineAndCharacterOfPosition',
],
};

const tsServicesTypes: Map<string, Set<string>> = new Map();
for (const [typeName, properties] of Object.entries(SERVICES_TYPES)) {
tsServicesTypes.set(typeName, new Set(properties));
}

const TYPESCRIPT_PACKAGE_PATH = '/node_modules/typescript/';

export default defineRule(({ typescript: ts, file, program, report }) => {
const typeChecker = program.getTypeChecker();

ts.forEachChild(file, function visit(node) {
if (ts.isPropertyAccessExpression(node)) {
checkAccess(node.expression, node.name.text, node.name.getStart(file), node.name.getEnd());
}
else if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression)) {
checkAccess(
node.expression,
node.argumentExpression.text,
node.argumentExpression.getStart(file),
node.argumentExpression.getEnd(),
);
}
ts.forEachChild(node, visit);
});

function isUnionOrIntersection(type: ts.Type): type is ts.UnionOrIntersectionType {
return (type.flags & (ts.TypeFlags.Union | ts.TypeFlags.Intersection)) !== 0;
}

function isTypescriptSourceFile(sourceFile: ts.SourceFile) {
return sourceFile.fileName.replace(/\\/g, '/').includes(TYPESCRIPT_PACKAGE_PATH);
}

function isTypescriptSymbol(symbol: ts.Symbol, typeName: string) {
if (symbol.getName() !== typeName) {
return false;
}
return (symbol.getDeclarations() ?? []).some(declaration => isTypescriptSourceFile(declaration.getSourceFile()));
}

function isTargetType(type: ts.Type, typeName: string): boolean {
if (isUnionOrIntersection(type)) {
return type.types.some(inner => isTargetType(inner, typeName));
}

const symbol = type.aliasSymbol ?? type.getSymbol();
if (!symbol) {
return false;
}

if (symbol.flags & ts.SymbolFlags.Alias) {
const aliasedSymbol = typeChecker.getAliasedSymbol(symbol);
if (aliasedSymbol && isTypescriptSymbol(aliasedSymbol, typeName)) {
return true;
}
}

return isTypescriptSymbol(symbol, typeName);
}

function checkAccess(target: ts.Expression, propertyName: string, start: number, end: number) {
for (const [typeName, properties] of tsServicesTypes) {
if (!properties.has(propertyName)) {
continue;
}
const type = typeChecker.getTypeAtLocation(target);
if (isTargetType(type, typeName)) {
report('TypeScript services types is used.', start, end);
break;
}
}
}
});
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
},
"include": [
"packages/*/tests",
"tsslint.config.ts"
"tsslint.config.ts",
"lint/*.ts"
],
"references": [
{ "path": "./packages/component-meta/tsconfig.json" },
Expand Down
Loading
Loading