Some checks failed
Deploy to Pages / build (pull_request) Failing after 1m56s
197 lines
5.5 KiB
JavaScript
197 lines
5.5 KiB
JavaScript
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import ts from 'typescript';
|
|
|
|
const projectRoot = process.cwd();
|
|
const sourceRoot = path.join(projectRoot, 'src');
|
|
|
|
const toPosix = (value) => value.split(path.sep).join('/');
|
|
|
|
const listTypeScriptFiles = (directory) =>
|
|
readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
const entryPath = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
return listTypeScriptFiles(entryPath);
|
|
}
|
|
return entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')
|
|
? [entryPath]
|
|
: [];
|
|
});
|
|
|
|
const files = listTypeScriptFiles(sourceRoot);
|
|
const fileSet = new Set(files.map((file) => path.resolve(file)));
|
|
|
|
const resolveModule = (fromFile, specifier) => {
|
|
if (!specifier.startsWith('.')) {
|
|
return null;
|
|
}
|
|
|
|
const base = path.resolve(path.dirname(fromFile), specifier);
|
|
const candidates = [
|
|
`${base}.ts`,
|
|
path.join(base, 'index.ts'),
|
|
base.endsWith('.ts') ? base : null,
|
|
].filter(Boolean);
|
|
|
|
return (
|
|
candidates.find((candidate) => existsSync(candidate) && fileSet.has(candidate)) ??
|
|
null
|
|
);
|
|
};
|
|
|
|
const exportKey = (file, name) => `${path.resolve(file)}:${name}`;
|
|
const isExported = (node) =>
|
|
ts.canHaveModifiers(node) &&
|
|
(ts.getModifiers(node) ?? []).some(
|
|
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword
|
|
);
|
|
const isDefaultExported = (node) =>
|
|
ts.canHaveModifiers(node) &&
|
|
(ts.getModifiers(node) ?? []).some(
|
|
(modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword
|
|
);
|
|
|
|
const exportedDeclarations = new Map();
|
|
const usedExports = new Set();
|
|
const wildcardUsedFiles = new Set();
|
|
|
|
const markUsed = (fromFile, name) => {
|
|
usedExports.add(exportKey(fromFile, name));
|
|
};
|
|
|
|
const collectImportUsage = (file, sourceFile) => {
|
|
sourceFile.forEachChild((node) => {
|
|
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
const resolved = resolveModule(file, node.moduleSpecifier.text);
|
|
if (!resolved || !node.importClause) {
|
|
return;
|
|
}
|
|
|
|
if (node.importClause.name) {
|
|
markUsed(resolved, 'default');
|
|
}
|
|
|
|
const namedBindings = node.importClause.namedBindings;
|
|
if (namedBindings && ts.isNamedImports(namedBindings)) {
|
|
namedBindings.elements.forEach((element) => {
|
|
markUsed(resolved, (element.propertyName ?? element.name).text);
|
|
});
|
|
} else if (namedBindings && ts.isNamespaceImport(namedBindings)) {
|
|
wildcardUsedFiles.add(path.resolve(resolved));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (
|
|
ts.isExportDeclaration(node) &&
|
|
node.moduleSpecifier &&
|
|
ts.isStringLiteral(node.moduleSpecifier)
|
|
) {
|
|
const resolved = resolveModule(file, node.moduleSpecifier.text);
|
|
if (!resolved) {
|
|
return;
|
|
}
|
|
|
|
if (!node.exportClause) {
|
|
wildcardUsedFiles.add(path.resolve(resolved));
|
|
return;
|
|
}
|
|
|
|
if (ts.isNamedExports(node.exportClause)) {
|
|
node.exportClause.elements.forEach((element) => {
|
|
markUsed(resolved, (element.propertyName ?? element.name).text);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
const collectExportedDeclarations = (file, sourceFile) => {
|
|
if (file.endsWith('.test.ts')) {
|
|
return;
|
|
}
|
|
|
|
sourceFile.forEachChild((node) => {
|
|
if (ts.isVariableStatement(node) && isExported(node)) {
|
|
node.declarationList.declarations.forEach((declaration) => {
|
|
if (ts.isIdentifier(declaration.name)) {
|
|
exportedDeclarations.set(exportKey(file, declaration.name.text), {
|
|
file,
|
|
name: declaration.name.text,
|
|
});
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (
|
|
(ts.isFunctionDeclaration(node) ||
|
|
ts.isClassDeclaration(node) ||
|
|
ts.isInterfaceDeclaration(node) ||
|
|
ts.isTypeAliasDeclaration(node) ||
|
|
ts.isEnumDeclaration(node)) &&
|
|
isExported(node)
|
|
) {
|
|
if (isDefaultExported(node)) {
|
|
exportedDeclarations.set(exportKey(file, 'default'), { file, name: 'default' });
|
|
return;
|
|
}
|
|
|
|
if (node.name) {
|
|
exportedDeclarations.set(exportKey(file, node.name.text), {
|
|
file,
|
|
name: node.name.text,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (
|
|
ts.isExportDeclaration(node) &&
|
|
!node.moduleSpecifier &&
|
|
node.exportClause &&
|
|
ts.isNamedExports(node.exportClause)
|
|
) {
|
|
node.exportClause.elements.forEach((element) => {
|
|
exportedDeclarations.set(exportKey(file, element.name.text), {
|
|
file,
|
|
name: element.name.text,
|
|
});
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
const parsedFiles = files.map((file) => ({
|
|
file,
|
|
sourceFile: ts.createSourceFile(
|
|
file,
|
|
readFileSync(file, 'utf8'),
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
ts.ScriptKind.TS
|
|
),
|
|
}));
|
|
|
|
parsedFiles.forEach(({ file, sourceFile }) => collectImportUsage(file, sourceFile));
|
|
parsedFiles.forEach(({ file, sourceFile }) =>
|
|
collectExportedDeclarations(file, sourceFile)
|
|
);
|
|
|
|
const unusedExports = Array.from(exportedDeclarations.entries())
|
|
.filter(
|
|
([key, declaration]) =>
|
|
!usedExports.has(key) && !wildcardUsedFiles.has(declaration.file)
|
|
)
|
|
.map(([, declaration]) => declaration)
|
|
.sort((left, right) =>
|
|
`${left.file}:${left.name}`.localeCompare(`${right.file}:${right.name}`)
|
|
);
|
|
|
|
if (unusedExports.length > 0) {
|
|
console.error('Unused exported declarations found:');
|
|
unusedExports.forEach(({ file, name }) => {
|
|
console.error(`- ${toPosix(path.relative(projectRoot, file))}: ${name}`);
|
|
});
|
|
process.exitCode = 1;
|
|
}
|