From ebe9ee0d413a6e7af8ef604489ccfb814e17af58 Mon Sep 17 00:00:00 2001 From: Rafael Santos Date: Thu, 18 Dec 2025 20:32:52 +0000 Subject: [PATCH] fix(no-undefined-types): support strict validation for TS namespaces - Adds `TSModuleDeclaration` to `closedTypes` to enforce strict validation of namespace members. - Recursively finds exported types (`TSTypeAliasDeclaration`, `TSInterfaceDeclaration`) within namespaces. - Adds support for interface properties within namespaces. - Updates tests to verify strict validation of namespace members. --- docs/rules/no-undefined-types.md | 75 ++++++++++++ src/rules/noUndefinedTypes.js | 58 ++++++++- test/rules/assertions/noUndefinedTypes.js | 136 ++++++++++++++++++++++ 3 files changed, 268 insertions(+), 1 deletion(-) diff --git a/docs/rules/no-undefined-types.md b/docs/rules/no-undefined-types.md index a09340a14..7397aa361 100644 --- a/docs/rules/no-undefined-types.md +++ b/docs/rules/no-undefined-types.md @@ -407,6 +407,25 @@ let getValue = (callback) => { callback(`hello`) } // Message: The type 'CustomElementConstructor' is undefined. + +class MyClass { + method() {} +} +/** @type {MyClass.nonExistent} */ +const a = 1; +// Message: The type 'MyClass.nonExistent' is undefined. + +declare namespace MyNamespace { + type MyType = string; + interface FooBar { + foobiz: string; + } +} +/** @type {MyNamespace.MyType} */ +const a = 's'; +/** @type {MyNamespace.OtherType} */ +const b = 's'; +// Message: The type 'MyNamespace.OtherType' is undefined. ```` @@ -1125,5 +1144,61 @@ export default Severities; const checkIsOnOf = (value, ...validValues) => { return validValues.includes(value); }; + +declare namespace MyNamespace { + type MyType = string; + interface FooBar { + foobiz: string; + } +} +/** @type {MyNamespace.MyType} */ +const a = 's'; +/** @type {MyNamespace.FooBar} */ +const c = { foobiz: 's' }; +/** @param {MyNamespace.FooBar['foobiz']} p */ +function f(p) {} + +declare namespace CoverageTest { + export class MyClass {} + export function myFunction(); + const local = 1; + export { local }; +} +/** @type {CoverageTest.MyClass} */ +const x = null; + +declare module "foo"; + +declare namespace MyNamespace { + interface I { + [key: string]: string; + (): void; + new (): void; + } +} + +declare namespace Nested.Namespace { + class C {} +} + +declare namespace NsWithInterface { + export interface HasIndexSig { + [key: string]: string; + normalProp: number; + } +} +/** @type {NsWithInterface.HasIndexSig} */ +const x = { normalProp: 1 }; +/** @type {NsWithInterface.HasIndexSig.normalProp} */ +const y = 1; + +declare namespace NsWithLiteralKey { + export interface HasLiteralKey { + "string-key": string; + normalProp: number; + } +} +/** @type {NsWithLiteralKey.HasLiteralKey} */ +const x = { "string-key": "value", normalProp: 1 }; ```` diff --git a/src/rules/noUndefinedTypes.js b/src/rules/noUndefinedTypes.js index 16bc1ad88..d96351cf5 100644 --- a/src/rules/noUndefinedTypes.js +++ b/src/rules/noUndefinedTypes.js @@ -274,6 +274,9 @@ export default iterateJsdoc(({ */ const imports = []; + /** @type {Set} */ + const closedTypes = new Set(); + const allDefinedTypes = new Set(globalScope.variables.map(({ name, }) => { @@ -296,6 +299,7 @@ export default iterateJsdoc(({ )?.parent; switch (globalItem?.type) { case 'ClassDeclaration': + closedTypes.add(name); return [ name, ...globalItem.body.body.map((item) => { @@ -330,6 +334,54 @@ export default iterateJsdoc(({ return `${name}.${property}`; }).filter(Boolean), ]; + case 'TSModuleDeclaration': + closedTypes.add(name); + return [ + name, + /* c8 ignore next -- Guard for ambient modules without body. */ + ...(globalItem.body?.body || []).flatMap((item) => { + /** @type {import('@typescript-eslint/types').TSESTree.ProgramStatement | import('@typescript-eslint/types').TSESTree.NamedExportDeclarations | null} */ + let declaration = item; + + if (item.type === 'ExportNamedDeclaration' && item.declaration) { + declaration = item.declaration; + } + + if (declaration.type === 'TSTypeAliasDeclaration' || declaration.type === 'ClassDeclaration') { + /* c8 ignore next 4 -- Guard for anonymous class declarations */ + if (!declaration.id) { + return []; + } + + return [ + `${name}.${declaration.id.name}`, + ]; + } + + if (declaration.type === 'TSInterfaceDeclaration') { + return [ + `${name}.${declaration.id.name}`, + ...declaration.body.body.map((prop) => { + // Only `TSPropertySignature` and `TSMethodSignature` have 'key'. + if (prop.type !== 'TSPropertySignature' && prop.type !== 'TSMethodSignature') { + return ''; + } + + // Key can be computed or a literal, only handle Identifier. + if (prop.key.type !== 'Identifier') { + return ''; + } + + const propName = prop.key.name; + /* c8 ignore next -- `propName` is always truthy for Identifiers */ + return propName ? `${name}.${declaration.id.name}.${propName}` : ''; + }).filter(Boolean), + ]; + } + + return []; + }), + ]; case 'VariableDeclarator': if (/** @type {import('@typescript-eslint/types').TSESTree.Identifier} */ ( /** @type {import('@typescript-eslint/types').TSESTree.CallExpression} */ ( @@ -529,9 +581,13 @@ export default iterateJsdoc(({ if (type === 'JsdocTypeName') { const structuredTypes = structuredTags[tag.tag]?.type; + const rootNamespace = val.split('.')[0]; + const isNamespaceValid = (definedTypes.includes(rootNamespace) || allDefinedTypes.has(rootNamespace)) && + !closedTypes.has(rootNamespace); + if (!allDefinedTypes.has(val) && !definedNamesAndNamepaths.has(val) && - (!Array.isArray(structuredTypes) || !structuredTypes.includes(val)) + (!Array.isArray(structuredTypes) || !structuredTypes.includes(val)) && !isNamespaceValid ) { const parent = /** diff --git a/test/rules/assertions/noUndefinedTypes.js b/test/rules/assertions/noUndefinedTypes.js index c8f9ba895..6cf8bc8e1 100644 --- a/test/rules/assertions/noUndefinedTypes.js +++ b/test/rules/assertions/noUndefinedTypes.js @@ -701,6 +701,44 @@ export default /** @type {import('../index.js').TestCases} */ ({ }, ], }, + { + code: ` + class MyClass { + method() {} + } + /** @type {MyClass.nonExistent} */ + const a = 1; + `, + errors: [ + { + line: 5, + message: 'The type \'MyClass.nonExistent\' is undefined.', + }, + ], + }, + { + code: ` + declare namespace MyNamespace { + type MyType = string; + interface FooBar { + foobiz: string; + } + } + /** @type {MyNamespace.MyType} */ + const a = 's'; + /** @type {MyNamespace.OtherType} */ + const b = 's'; + `, + errors: [ + { + line: 10, + message: 'The type \'MyNamespace.OtherType\' is undefined.', + }, + ], + languageOptions: { + parser: typescriptEslintParser, + }, + }, ], valid: [ { @@ -1904,5 +1942,103 @@ export default /** @type {import('../index.js').TestCases} */ ({ }; `, }, + { + code: ` + declare namespace MyNamespace { + type MyType = string; + interface FooBar { + foobiz: string; + } + } + /** @type {MyNamespace.MyType} */ + const a = 's'; + /** @type {MyNamespace.FooBar} */ + const c = { foobiz: 's' }; + /** @param {MyNamespace.FooBar['foobiz']} p */ + function f(p) {} + `, + languageOptions: { + parser: typescriptEslintParser, + }, + }, + { + code: ` + declare namespace CoverageTest { + export class MyClass {} + export function myFunction(); + const local = 1; + export { local }; + } + /** @type {CoverageTest.MyClass} */ + const x = null; + `, + languageOptions: { + parser: typescriptEslintParser, + }, + }, + { + code: ` + declare module "foo"; + `, + languageOptions: { + parser: typescriptEslintParser, + }, + }, + { + code: ` + declare namespace MyNamespace { + interface I { + [key: string]: string; + (): void; + new (): void; + } + } + `, + languageOptions: { + parser: typescriptEslintParser, + }, + }, + { + code: ` + declare namespace Nested.Namespace { + class C {} + } + `, + languageOptions: { + parser: typescriptEslintParser, + }, + }, + { + code: ` + declare namespace NsWithInterface { + export interface HasIndexSig { + [key: string]: string; + normalProp: number; + } + } + /** @type {NsWithInterface.HasIndexSig} */ + const x = { normalProp: 1 }; + /** @type {NsWithInterface.HasIndexSig.normalProp} */ + const y = 1; + `, + languageOptions: { + parser: typescriptEslintParser, + }, + }, + { + code: ` + declare namespace NsWithLiteralKey { + export interface HasLiteralKey { + "string-key": string; + normalProp: number; + } + } + /** @type {NsWithLiteralKey.HasLiteralKey} */ + const x = { "string-key": "value", normalProp: 1 }; + `, + languageOptions: { + parser: typescriptEslintParser, + }, + }, ], });