'use strict';
const fs = require('fs');
const path = require('path');
const ts = require('typescript');
const { hasTag, isJsdoc } = require('./jsdoc-utils.js');
const publicExportsCache = new Map();
module.exports = {
meta: {
schema: [
{
type: 'object',
properties: {
publicIndexFiles: {
type: 'array',
items: { type: 'string' },
},
},
additionalProperties: false,
},
],
},
create: requirePublicApiExports,
};
function requirePublicApiExports(context) {
const sourceCode = context.getSourceCode();
const publicApi = createPublicApi(context);
function hasPublicDoc(node) {
const comment = ownJsdocComment(node);
return comment != null && !hasTag(comment, 'internal');
}
function hasInternalDoc(node) {
const comment = ownJsdocComment(node);
return comment != null && hasTag(comment, 'internal');
}
function ownJsdocComment(node) {
const parent = node.parent;
const commentTarget =
parent?.type === 'ExportNamedDeclaration' && parent.declaration === node
? parent
: node;
const comments = sourceCode
.getCommentsBefore(commentTarget)
.filter(isJsdoc);
const comment = comments[comments.length - 1];
if (comment == null || hasCodeBetween(comment, commentTarget)) {
return null;
}
return comment;
}
function hasCodeBetween(comment, node) {
return sourceCode
.getTokensBetween(comment, node, { includeComments: true })
.some((token) => token.type !== 'Block' && token.type !== 'Line');
}
function reportMissingPublicDoc(node, name) {
context.report({
node,
message: `Public API declaration "${name}" is exported by a public index.ts file and must have public JSDoc.`,
});
}
function reportUnexpectedInternalDoc(node, name) {
context.report({
node,
message: `Public API declaration "${name}" is exported by a public index.ts file and must not have @internal JSDoc.`,
});
}
function reportMissingInternalDoc(node, name) {
context.report({
node,
message: `Internal declaration "${name}" is exported from src but is not exported by a public index.ts file and must have @internal JSDoc.`,
});
}
function requirePublicDoc(node, name) {
if (!hasPublicDoc(node)) {
reportMissingPublicDoc(node, name);
}
}
return {
ExportNamedDeclaration(node) {
publicApi.trackLocalExports(node);
},
'Program:exit'(program) {
const topLevelOverloads = overloadCounts(
program.body,
(statement) => unwrapExportedDeclaration(statement)?.id?.name,
);
for (const statement of program.body) {
const namespaceName = namespaceExportName(statement);
if (namespaceName != null) {
if (publicApi.isPublic(namespaceName)) {
if (hasInternalDoc(statement)) {
reportUnexpectedInternalDoc(statement, namespaceName);
}
requirePublicDoc(statement, namespaceName);
} else if (!hasInternalDoc(statement)) {
reportMissingInternalDoc(statement, namespaceName);
}
}
const declaration = publicApi.exportedDeclaration(statement);
if (isDocumentableDeclaration(declaration)) {
for (const name of declarationNames(declaration)) {
if (publicApi.isPublic(name)) {
if (
isOverloadImplementation(declaration, topLevelOverloads, name)
) {
if (!hasInternalDoc(declaration)) {
reportMissingInternalDoc(declaration, name);
}
continue;
}
if (hasInternalDoc(declaration)) {
reportUnexpectedInternalDoc(declaration, name);
}
requirePublicDoc(declaration, name);
for (const member of publicMembers(declaration)) {
const qualifiedMemberName = `${name}.${memberName(member)}`;
if (!hasInternalDoc(member)) {
requirePublicDoc(member, qualifiedMemberName);
}
}
} else if (!hasInternalDoc(declaration)) {
reportMissingInternalDoc(declaration, name);
}
}
}
}
},
};
}
function createPublicApi(context) {
const cwd = context.getCwd();
const filename = normalizePath(path.relative(cwd, context.getFilename()));
const publicIndexFiles =
context.options[0]?.publicIndexFiles ?? findIndexFiles(cwd);
const publicExports = collectPublicExports(cwd, publicIndexFiles);
const names = allPublicNamesForFile(publicExports, filename);
const localExports = new Set();
return {
isPublic: (name) => names.has(name),
trackLocalExports(node) {
if (node.source != null || node.specifiers == null) {
return;
}
for (const specifier of node.specifiers) {
if (specifier.local?.name != null) {
localExports.add(specifier.local.name);
}
}
},
exportedDeclaration(statement) {
if (statement.type === 'ExportNamedDeclaration') {
return isDocumentableDeclaration(statement.declaration)
? statement.declaration
: null;
}
if (
isDocumentableDeclaration(statement) &&
declarationNames(statement).some((name) => localExports.has(name))
) {
return statement;
}
return null;
},
};
}
function unwrapExportedDeclaration(statement) {
return statement.type === 'ExportNamedDeclaration'
? statement.declaration
: statement;
}
function namespaceExportName(statement) {
return statement.type === 'ExportAllDeclaration'
? statement.exported?.name
: null;
}
function findIndexFiles(cwd, dir = 'src') {
const absoluteDir = path.join(cwd, dir);
if (!fs.existsSync(absoluteDir)) {
return [];
}
const files = [];
for (const entry of fs.readdirSync(absoluteDir, { withFileTypes: true })) {
const file = normalizePath(path.join(dir, entry.name));
if (entry.isDirectory()) {
if (!entry.name.startsWith('__')) {
files.push(...findIndexFiles(cwd, file));
}
} else if (entry.isFile() && entry.name === 'index.ts') {
files.push(file);
}
}
return files.sort();
}
function collectPublicExports(cwd, publicIndexFiles) {
const cacheKey = `${cwd}\0${publicIndexFiles.join('\0')}`;
const cached = publicExportsCache.get(cacheKey);
if (cached != null) {
return cached;
}
const exportTables = new Map();
const publicExports = new Map();
function exportTable(indexFile) {
const cachedTable = exportTables.get(indexFile);
if (cachedTable != null) {
return cachedTable;
}
const table = new Map();
exportTables.set(indexFile, table);
const ast = sourceFile(cwd, indexFile);
for (const statement of ast.statements) {
if (ts.isExportDeclaration(statement)) {
addExportDeclaration(table, statement, indexFile);
} else if (hasExportModifier(statement)) {
for (const name of tsDeclarationNames(statement)) {
addExportedOrigin(table, name, { file: indexFile, name });
}
}
}
return table;
}
function addExportDeclaration(table, statement, file) {
if (
statement.moduleSpecifier == null ||
!ts.isStringLiteral(statement.moduleSpecifier)
) {
return;
}
const targetFile = resolveModule(cwd, file, statement.moduleSpecifier.text);
if (targetFile == null) {
return;
}
if (statement.exportClause == null) {
return;
}
if (ts.isNamespaceExport(statement.exportClause)) {
const namespaceName = statement.exportClause.name.text;
addExportedOrigin(table, namespaceName, { file, name: namespaceName });
const targetTable = exportTable(targetFile);
for (const origins of targetTable.values()) {
for (const origin of origins) {
addExportedOrigin(table, namespaceName, origin);
}
}
return;
}
if (!ts.isNamedExports(statement.exportClause)) {
return;
}
const targetTable = exportTable(targetFile);
for (const element of statement.exportClause.elements) {
const importedName = (element.propertyName ?? element.name).text;
const exportedName = element.name.text;
const origins = targetTable?.get(importedName) ?? [
{ file: targetFile, name: importedName },
];
for (const origin of origins) {
addExportedOrigin(table, exportedName, origin);
}
}
}
for (const indexFile of publicIndexFiles) {
const table = exportTable(indexFile);
const exportsForIndex = new Map();
for (const origins of table.values()) {
for (const origin of origins) {
addPublicExport(exportsForIndex, origin.file, origin.name);
}
}
publicExports.set(indexFile, exportsForIndex);
}
publicExportsCache.set(cacheKey, publicExports);
return publicExports;
}
function allPublicNamesForFile(publicExports, file) {
const names = new Set();
for (const exportsForIndex of publicExports.values()) {
for (const name of exportsForIndex.get(file) ?? []) {
names.add(name);
}
}
return names;
}
function sourceFile(cwd, file) {
return ts.createSourceFile(
file,
fs.readFileSync(path.join(cwd, file), 'utf8'),
ts.ScriptTarget.Latest,
true,
);
}
function resolveModule(cwd, indexFile, specifier) {
const base = normalizePath(path.join(path.dirname(indexFile), specifier));
const candidates = specifier.endsWith('.ts')
? [base]
: [`${base}.ts`, path.join(base, 'index.ts')];
return candidates.find((file) => fs.existsSync(path.join(cwd, file)));
}
function addExportedOrigin(table, exportedName, origin) {
if (!table.has(exportedName)) {
table.set(exportedName, []);
}
table.get(exportedName).push(origin);
}
function addPublicExport(map, file, name) {
if (!map.has(file)) {
map.set(file, new Set());
}
map.get(file).add(name);
}
function hasExportModifier(node) {
return node.modifiers?.some(
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
);
}
function tsDeclarationNames(node) {
if (ts.isVariableStatement(node)) {
return node.declarationList.declarations
.map((declaration) =>
ts.isIdentifier(declaration.name) ? declaration.name.text : null,
)
.filter(Boolean);
}
return node.name?.text == null ? [] : [node.name.text];
}
function publicMembers(declaration) {
if (declaration.type === 'ClassDeclaration') {
const overloads = overloadCounts(declaration.body.body, memberName);
return declaration.body.body.filter(
(member) =>
member.accessibility !== 'private' &&
!isOverloadImplementation(member, overloads, memberName(member)),
);
}
if (declaration.type === 'TSInterfaceDeclaration') {
return declaration.body.body.filter(isDocumentableTypeMember);
}
if (declaration.type === 'TSTypeAliasDeclaration') {
return typeLiteralMembers(declaration.typeAnnotation).filter(
isDocumentableTypeMember,
);
}
if (declaration.type === 'TSEnumDeclaration') {
return declaration.members;
}
return [];
}
function overloadCounts(nodes, nameOfNode) {
const counts = new Map();
for (const node of nodes) {
const name = nameOfNode(node);
if (name != null) {
counts.set(name, (counts.get(name) ?? 0) + 1);
}
}
return counts;
}
function isOverloadImplementation(node, overloads, name) {
return (
isCallable(node) && overloads.get(name) > 1 && callableBody(node) != null
);
}
function typeLiteralMembers(typeAnnotation) {
if (typeAnnotation == null) {
return [];
}
if (typeAnnotation.type === 'TSTypeLiteral') {
return typeAnnotation.members;
}
if (
typeAnnotation.type === 'TSIntersectionType' ||
typeAnnotation.type === 'TSUnionType'
) {
return typeAnnotation.types.flatMap(typeLiteralMembers);
}
return [];
}
function isCallable(node) {
return (
node?.type === 'FunctionDeclaration' ||
node?.type === 'MethodDefinition' ||
node?.type === 'TSDeclareFunction' ||
node?.type === 'TSMethodSignature'
);
}
function callableBody(node) {
return node.body ?? node.value?.body;
}
function isDocumentableTypeMember(member) {
return member.type !== 'TSIndexSignature';
}
function isDocumentableDeclaration(node) {
return (
node?.type === 'ClassDeclaration' ||
node?.type === 'FunctionDeclaration' ||
node?.type === 'TSDeclareFunction' ||
node?.type === 'TSInterfaceDeclaration' ||
node?.type === 'TSTypeAliasDeclaration' ||
node?.type === 'TSEnumDeclaration' ||
node?.type === 'VariableDeclaration'
);
}
function declarationNames(node) {
if (node.type === 'VariableDeclaration') {
return node.declarations
.map((declaration) =>
declaration.id.type === 'Identifier' ? declaration.id.name : null,
)
.filter(Boolean);
}
return node.id?.name == null ? [] : [node.id.name];
}
function memberName(member) {
const key = member.key ?? member.id;
if (member.kind === 'constructor') {
return 'constructor';
}
if (key?.type === 'Identifier') {
return key.name;
}
if (key?.type === 'Literal') {
return String(key.value);
}
return '<computed>';
}
function normalizePath(file) {
return file.split(path.sep).join('/');
}