Skip to content

Instantly share code, notes, and snippets.

@tluyben
Created April 13, 2025 13:40
Show Gist options
  • Save tluyben/5a6b55ded613cb108a7ddef26216d5ef to your computer and use it in GitHub Desktop.
Save tluyben/5a6b55ded613cb108a7ddef26216d5ef to your computer and use it in GitHub Desktop.
Validate tsx files for imports you do or do not allow
import * as fs from "fs";
import * as path from "path";
import typescript from "typescript";
import { sync as globSync } from "glob";
interface LinterConfig {
allow: string[];
disallow: string[];
}
function isRegexString(str: string): boolean {
try {
new RegExp(str);
return true;
} catch {
return false;
}
}
function matchesPattern(packageName: string, pattern: string): boolean {
if (isRegexString(pattern)) {
const regex = new RegExp(pattern);
return regex.test(packageName);
}
return packageName === pattern;
}
function isPackageAllowed(
packageName: string,
config: LinterConfig
): { allowed: boolean; reason: string } {
// Check if package is explicitly disallowed
for (const disallowed of config.disallow) {
if (matchesPattern(packageName, disallowed)) {
return {
allowed: false,
reason: `Package "${packageName}" is disallowed by pattern "${disallowed}"`,
};
}
}
// If allow list is empty, all packages are allowed
if (config.allow.length === 0) {
return { allowed: true, reason: "" };
}
// Check if package is in allow list
for (const allowed of config.allow) {
if (matchesPattern(packageName, allowed)) {
return { allowed: true, reason: "" };
}
}
return {
allowed: false,
reason: `Package "${packageName}" is not in the allowed list`,
};
}
function findImports(sourceFile: typescript.SourceFile): string[] {
const imports: string[] = [];
function visit(node: typescript.Node) {
if (typescript.isImportDeclaration(node)) {
const moduleSpecifier = node.moduleSpecifier
.getText()
.replace(/['"]/g, "");
if (!moduleSpecifier.startsWith(".")) {
imports.push(moduleSpecifier);
}
}
typescript.forEachChild(node, visit);
}
visit(sourceFile);
return imports;
}
function lintFile(filePath: string, config: LinterConfig): string[] {
const errors: string[] = [];
const sourceText = fs.readFileSync(filePath, "utf-8");
const sourceFile = typescript.createSourceFile(
filePath,
sourceText,
typescript.ScriptTarget.ESNext,
true
);
const imports = findImports(sourceFile);
for (const importPath of imports) {
const result = isPackageAllowed(importPath, config);
if (!result.allowed) {
errors.push(`${filePath}: ${result.reason}`);
}
}
return errors;
}
function main() {
// Read config file
const configPath = path.join(process.cwd(), "tsx-linter.json");
if (!fs.existsSync(configPath)) {
console.error("Error: tsx-linter.json not found in root directory");
process.exit(1);
}
const config: LinterConfig = JSON.parse(fs.readFileSync(configPath, "utf-8"));
// Find all TSX files
const tsxFiles = globSync("**/*.tsx", { ignore: ["node_modules/**"] });
let hasErrors = false;
const allErrors: string[] = [];
// Lint each file
for (const file of tsxFiles) {
const errors = lintFile(file, config);
if (errors.length > 0) {
hasErrors = true;
allErrors.push(...errors);
}
}
// Print all errors
if (hasErrors) {
console.error("\nLinting errors found:");
allErrors.forEach((error) => console.error(error));
process.exit(1);
} else {
console.log("No linting errors found.");
}
}
main();
@tluyben
Copy link
Author

tluyben commented Apr 13, 2025

Example;

{
  "allow": [
    "react",
    "react-dom",
    "@types/*",
    "@/components/*",
    "@/lib/*",
    "@/hooks/*",
    "class-variance-authority",
    "next-themes",
    "sonner",
    "input-otp",
    "vaul",
    "cmdk",
    "recharts",
    "recharts/*",
    "@radix-ui/*",
    "lucide-react",
    "tailwind-merge",
    "clsx",
    "zod",
    "zustand"
  ],
  "disallow": ["jquery", "moment", "lodash"]
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment