feat(guardian): add guardian package - code quality analyzer

Add @puaros/guardian package v0.1.0 - code quality guardian for vibe coders and enterprise teams.

Features:
- Hardcode detection (magic numbers, magic strings)
- Circular dependency detection
- Naming convention enforcement (Clean Architecture)
- Architecture violation detection
- CLI tool with comprehensive reporting
- 159 tests with 80%+ coverage
- Smart suggestions for fixes
- Built for AI-assisted development

Built with Clean Architecture and DDD principles.
Works with Claude, GPT, Copilot, Cursor, and any AI coding assistant.
This commit is contained in:
imfozilbek
2025-11-24 02:54:39 +05:00
parent 9f97509b06
commit 03705b5264
96 changed files with 9520 additions and 0 deletions

View File

@@ -0,0 +1,375 @@
import { IHardcodeDetector } from "../../domain/services/IHardcodeDetector"
import { HardcodedValue } from "../../domain/value-objects/HardcodedValue"
import { ALLOWED_NUMBERS, CODE_PATTERNS, DETECTION_KEYWORDS } from "../constants/defaults"
import { HARDCODE_TYPES } from "../../shared/constants"
/**
* Detects hardcoded values (magic numbers and strings) in TypeScript/JavaScript code
*
* This detector identifies configuration values, URLs, timeouts, ports, and other
* constants that should be extracted to configuration files. It uses pattern matching
* and context analysis to reduce false positives.
*
* @example
* ```typescript
* const detector = new HardcodeDetector()
* const code = `
* const timeout = 5000
* const url = "http://localhost:8080"
* `
* const violations = detector.detectAll(code, 'config.ts')
* // Returns array of HardcodedValue objects
* ```
*/
export class HardcodeDetector implements IHardcodeDetector {
private readonly ALLOWED_NUMBERS = ALLOWED_NUMBERS
private readonly ALLOWED_STRING_PATTERNS = [/^[a-z]$/i, /^\/$/, /^\\$/, /^\s+$/, /^,$/, /^\.$/]
/**
* Detects all hardcoded values (both numbers and strings) in the given code
*
* @param code - Source code to analyze
* @param filePath - File path for context (used in violation reports)
* @returns Array of detected hardcoded values with suggestions
*/
public detectAll(code: string, filePath: string): HardcodedValue[] {
const magicNumbers = this.detectMagicNumbers(code, filePath)
const magicStrings = this.detectMagicStrings(code, filePath)
return [...magicNumbers, ...magicStrings]
}
/**
* Check if a line is inside an exported constant definition
*/
private isInExportedConstant(lines: string[], lineIndex: number): boolean {
const currentLineTrimmed = lines[lineIndex].trim()
if (this.isSingleLineExportConst(currentLineTrimmed)) {
return true
}
const exportConstStart = this.findExportConstStart(lines, lineIndex)
if (exportConstStart === -1) {
return false
}
const { braces, brackets } = this.countUnclosedBraces(lines, exportConstStart, lineIndex)
return braces > 0 || brackets > 0
}
/**
* Check if a line is a single-line export const declaration
*/
private isSingleLineExportConst(line: string): boolean {
if (!line.startsWith(CODE_PATTERNS.EXPORT_CONST)) {
return false
}
const hasObjectOrArray =
line.includes(CODE_PATTERNS.OBJECT_START) || line.includes(CODE_PATTERNS.ARRAY_START)
if (hasObjectOrArray) {
const hasAsConstEnding =
line.includes(CODE_PATTERNS.AS_CONST_OBJECT) ||
line.includes(CODE_PATTERNS.AS_CONST_ARRAY) ||
line.includes(CODE_PATTERNS.AS_CONST_END_SEMICOLON_OBJECT) ||
line.includes(CODE_PATTERNS.AS_CONST_END_SEMICOLON_ARRAY)
return hasAsConstEnding
}
return line.includes(CODE_PATTERNS.AS_CONST)
}
/**
* Find the starting line of an export const declaration
*/
private findExportConstStart(lines: string[], lineIndex: number): number {
for (let currentLine = lineIndex; currentLine >= 0; currentLine--) {
const trimmed = lines[currentLine].trim()
const isExportConst =
trimmed.startsWith(CODE_PATTERNS.EXPORT_CONST) &&
(trimmed.includes(CODE_PATTERNS.OBJECT_START) ||
trimmed.includes(CODE_PATTERNS.ARRAY_START))
if (isExportConst) {
return currentLine
}
const isTopLevelStatement =
currentLine < lineIndex &&
(trimmed.startsWith(CODE_PATTERNS.EXPORT) ||
trimmed.startsWith(CODE_PATTERNS.IMPORT))
if (isTopLevelStatement) {
break
}
}
return -1
}
/**
* Count unclosed braces and brackets between two line indices
*/
private countUnclosedBraces(
lines: string[],
startLine: number,
endLine: number,
): { braces: number; brackets: number } {
let braces = 0
let brackets = 0
for (let i = startLine; i <= endLine; i++) {
const line = lines[i]
let inString = false
let stringChar = ""
for (let j = 0; j < line.length; j++) {
const char = line[j]
const prevChar = j > 0 ? line[j - 1] : ""
if ((char === "'" || char === '"' || char === "`") && prevChar !== "\\") {
if (!inString) {
inString = true
stringChar = char
} else if (char === stringChar) {
inString = false
stringChar = ""
}
}
if (!inString) {
if (char === "{") {
braces++
} else if (char === "}") {
braces--
} else if (char === "[") {
brackets++
} else if (char === "]") {
brackets--
}
}
}
}
return { braces, brackets }
}
/**
* Detects magic numbers in code (timeouts, ports, limits, retries, etc.)
*
* Skips allowed numbers (-1, 0, 1, 2, 10, 100, 1000) and values in exported constants
*
* @param code - Source code to analyze
* @param _filePath - File path (currently unused, reserved for future use)
* @returns Array of detected magic numbers
*/
public detectMagicNumbers(code: string, _filePath: string): HardcodedValue[] {
const results: HardcodedValue[] = []
const lines = code.split("\n")
const numberPatterns = [
/(?:setTimeout|setInterval)\s*\(\s*[^,]+,\s*(\d+)/g,
/(?:maxRetries|retries|attempts)\s*[=:]\s*(\d+)/gi,
/(?:limit|max|min)\s*[=:]\s*(\d+)/gi,
/(?:port|PORT)\s*[=:]\s*(\d+)/g,
/(?:delay|timeout|TIMEOUT)\s*[=:]\s*(\d+)/gi,
]
lines.forEach((line, lineIndex) => {
if (line.trim().startsWith("//") || line.trim().startsWith("*")) {
return
}
// Skip lines inside exported constants
if (this.isInExportedConstant(lines, lineIndex)) {
return
}
numberPatterns.forEach((pattern) => {
let match
const regex = new RegExp(pattern)
while ((match = regex.exec(line)) !== null) {
const value = parseInt(match[1], 10)
if (!this.ALLOWED_NUMBERS.has(value)) {
results.push(
HardcodedValue.create(
value,
HARDCODE_TYPES.MAGIC_NUMBER,
lineIndex + 1,
match.index,
line.trim(),
),
)
}
}
})
const genericNumberRegex = /\b(\d{3,})\b/g
let match
while ((match = genericNumberRegex.exec(line)) !== null) {
const value = parseInt(match[1], 10)
if (
!this.ALLOWED_NUMBERS.has(value) &&
!this.isInComment(line, match.index) &&
!this.isInString(line, match.index)
) {
const context = this.extractContext(line, match.index)
if (this.looksLikeMagicNumber(context)) {
results.push(
HardcodedValue.create(
value,
HARDCODE_TYPES.MAGIC_NUMBER,
lineIndex + 1,
match.index,
line.trim(),
),
)
}
}
}
})
return results
}
/**
* Detects magic strings in code (URLs, connection strings, error messages, etc.)
*
* Skips short strings (≤3 chars), console logs, test descriptions, imports,
* and values in exported constants
*
* @param code - Source code to analyze
* @param _filePath - File path (currently unused, reserved for future use)
* @returns Array of detected magic strings
*/
public detectMagicStrings(code: string, _filePath: string): HardcodedValue[] {
const results: HardcodedValue[] = []
const lines = code.split("\n")
const stringRegex = /(['"`])(?:(?!\1).)+\1/g
lines.forEach((line, lineIndex) => {
if (
line.trim().startsWith("//") ||
line.trim().startsWith("*") ||
line.includes("import ") ||
line.includes("from ")
) {
return
}
// Skip lines inside exported constants
if (this.isInExportedConstant(lines, lineIndex)) {
return
}
let match
const regex = new RegExp(stringRegex)
while ((match = regex.exec(line)) !== null) {
const fullMatch = match[0]
const value = fullMatch.slice(1, -1)
// Skip template literals (backtick strings with ${} interpolation)
if (fullMatch.startsWith("`") || value.includes("${")) {
continue
}
if (!this.isAllowedString(value) && this.looksLikeMagicString(line, value)) {
results.push(
HardcodedValue.create(
value,
HARDCODE_TYPES.MAGIC_STRING,
lineIndex + 1,
match.index,
line.trim(),
),
)
}
}
})
return results
}
private isAllowedString(str: string): boolean {
if (str.length <= 1) {
return true
}
return this.ALLOWED_STRING_PATTERNS.some((pattern) => pattern.test(str))
}
private looksLikeMagicString(line: string, value: string): boolean {
const lowerLine = line.toLowerCase()
if (
lowerLine.includes(DETECTION_KEYWORDS.TEST) ||
lowerLine.includes(DETECTION_KEYWORDS.DESCRIBE)
) {
return false
}
if (
lowerLine.includes(DETECTION_KEYWORDS.CONSOLE_LOG) ||
lowerLine.includes(DETECTION_KEYWORDS.CONSOLE_ERROR)
) {
return false
}
if (value.includes(DETECTION_KEYWORDS.HTTP) || value.includes(DETECTION_KEYWORDS.API)) {
return true
}
if (/^\d{2,}$/.test(value)) {
return false
}
return value.length > 3
}
private looksLikeMagicNumber(context: string): boolean {
const lowerContext = context.toLowerCase()
const configKeywords = [
DETECTION_KEYWORDS.TIMEOUT,
DETECTION_KEYWORDS.DELAY,
DETECTION_KEYWORDS.RETRY,
DETECTION_KEYWORDS.LIMIT,
DETECTION_KEYWORDS.MAX,
DETECTION_KEYWORDS.MIN,
DETECTION_KEYWORDS.PORT,
DETECTION_KEYWORDS.INTERVAL,
]
return configKeywords.some((keyword) => lowerContext.includes(keyword))
}
private isInComment(line: string, index: number): boolean {
const beforeIndex = line.substring(0, index)
return beforeIndex.includes("//") || beforeIndex.includes("/*")
}
private isInString(line: string, index: number): boolean {
const beforeIndex = line.substring(0, index)
const singleQuotes = (beforeIndex.match(/'/g) ?? []).length
const doubleQuotes = (beforeIndex.match(/"/g) ?? []).length
const backticks = (beforeIndex.match(/`/g) ?? []).length
return singleQuotes % 2 !== 0 || doubleQuotes % 2 !== 0 || backticks % 2 !== 0
}
private extractContext(line: string, index: number): string {
const start = Math.max(0, index - 30)
const end = Math.min(line.length, index + 30)
return line.substring(start, end)
}
}

View File

@@ -0,0 +1,277 @@
import { INamingConventionDetector } from "../../domain/services/INamingConventionDetector"
import { NamingViolation } from "../../domain/value-objects/NamingViolation"
import {
LAYERS,
NAMING_VIOLATION_TYPES,
NAMING_PATTERNS,
USE_CASE_VERBS,
} from "../../shared/constants/rules"
import {
EXCLUDED_FILES,
FILE_SUFFIXES,
PATH_PATTERNS,
PATTERN_WORDS,
NAMING_ERROR_MESSAGES,
} from "../constants/detectorPatterns"
/**
* Detects naming convention violations based on Clean Architecture layers
*
* This detector ensures that files follow naming conventions appropriate to their layer:
* - Domain: Entities (nouns), Services (*Service), Value Objects, Repository interfaces (I*Repository)
* - Application: Use cases (verbs), DTOs (*Dto/*Request/*Response), Mappers (*Mapper)
* - Infrastructure: Controllers (*Controller), Repository implementations (*Repository), Services (*Service/*Adapter)
*
* @example
* ```typescript
* const detector = new NamingConventionDetector()
* const violations = detector.detectViolations('UserDto.ts', 'domain', 'src/domain/UserDto.ts')
* // Returns violation: DTOs should not be in domain layer
* ```
*/
export class NamingConventionDetector implements INamingConventionDetector {
public detectViolations(
fileName: string,
layer: string | undefined,
filePath: string,
): NamingViolation[] {
if (!layer) {
return []
}
if ((EXCLUDED_FILES as readonly string[]).includes(fileName)) {
return []
}
switch (layer) {
case LAYERS.DOMAIN:
return this.checkDomainLayer(fileName, filePath)
case LAYERS.APPLICATION:
return this.checkApplicationLayer(fileName, filePath)
case LAYERS.INFRASTRUCTURE:
return this.checkInfrastructureLayer(fileName, filePath)
case LAYERS.SHARED:
return []
default:
return []
}
}
private checkDomainLayer(fileName: string, filePath: string): NamingViolation[] {
const violations: NamingViolation[] = []
const forbiddenPatterns = NAMING_PATTERNS.DOMAIN.ENTITY.forbidden ?? []
for (const forbidden of forbiddenPatterns) {
if (fileName.includes(forbidden)) {
violations.push(
NamingViolation.create(
fileName,
NAMING_VIOLATION_TYPES.FORBIDDEN_PATTERN,
LAYERS.DOMAIN,
filePath,
NAMING_ERROR_MESSAGES.DOMAIN_FORBIDDEN,
fileName,
`Move to application or infrastructure layer, or rename to follow domain patterns`,
),
)
return violations
}
}
if (fileName.endsWith(FILE_SUFFIXES.SERVICE)) {
if (!NAMING_PATTERNS.DOMAIN.SERVICE.pattern.test(fileName)) {
violations.push(
NamingViolation.create(
fileName,
NAMING_VIOLATION_TYPES.WRONG_CASE,
LAYERS.DOMAIN,
filePath,
NAMING_PATTERNS.DOMAIN.SERVICE.description,
fileName,
),
)
}
return violations
}
if (
fileName.startsWith(PATTERN_WORDS.I_PREFIX) &&
fileName.includes(PATTERN_WORDS.REPOSITORY)
) {
if (!NAMING_PATTERNS.DOMAIN.REPOSITORY_INTERFACE.pattern.test(fileName)) {
violations.push(
NamingViolation.create(
fileName,
NAMING_VIOLATION_TYPES.WRONG_PREFIX,
LAYERS.DOMAIN,
filePath,
NAMING_PATTERNS.DOMAIN.REPOSITORY_INTERFACE.description,
fileName,
),
)
}
return violations
}
if (!NAMING_PATTERNS.DOMAIN.ENTITY.pattern.test(fileName)) {
violations.push(
NamingViolation.create(
fileName,
NAMING_VIOLATION_TYPES.WRONG_CASE,
LAYERS.DOMAIN,
filePath,
NAMING_PATTERNS.DOMAIN.ENTITY.description,
fileName,
NAMING_ERROR_MESSAGES.USE_PASCAL_CASE,
),
)
}
return violations
}
private checkApplicationLayer(fileName: string, filePath: string): NamingViolation[] {
const violations: NamingViolation[] = []
if (
fileName.endsWith(FILE_SUFFIXES.DTO) ||
fileName.endsWith(FILE_SUFFIXES.REQUEST) ||
fileName.endsWith(FILE_SUFFIXES.RESPONSE)
) {
if (!NAMING_PATTERNS.APPLICATION.DTO.pattern.test(fileName)) {
violations.push(
NamingViolation.create(
fileName,
NAMING_VIOLATION_TYPES.WRONG_SUFFIX,
LAYERS.APPLICATION,
filePath,
NAMING_PATTERNS.APPLICATION.DTO.description,
fileName,
NAMING_ERROR_MESSAGES.USE_DTO_SUFFIX,
),
)
}
return violations
}
if (fileName.endsWith(FILE_SUFFIXES.MAPPER)) {
if (!NAMING_PATTERNS.APPLICATION.MAPPER.pattern.test(fileName)) {
violations.push(
NamingViolation.create(
fileName,
NAMING_VIOLATION_TYPES.WRONG_SUFFIX,
LAYERS.APPLICATION,
filePath,
NAMING_PATTERNS.APPLICATION.MAPPER.description,
fileName,
),
)
}
return violations
}
const startsWithVerb = this.startsWithCommonVerb(fileName)
if (startsWithVerb) {
if (!NAMING_PATTERNS.APPLICATION.USE_CASE.pattern.test(fileName)) {
violations.push(
NamingViolation.create(
fileName,
NAMING_VIOLATION_TYPES.WRONG_VERB_NOUN,
LAYERS.APPLICATION,
filePath,
NAMING_PATTERNS.APPLICATION.USE_CASE.description,
fileName,
NAMING_ERROR_MESSAGES.USE_VERB_NOUN,
),
)
}
return violations
}
if (
filePath.includes(PATH_PATTERNS.USE_CASES) ||
filePath.includes(PATH_PATTERNS.USE_CASES_ALT)
) {
const hasVerb = this.startsWithCommonVerb(fileName)
if (!hasVerb) {
violations.push(
NamingViolation.create(
fileName,
NAMING_VIOLATION_TYPES.WRONG_VERB_NOUN,
LAYERS.APPLICATION,
filePath,
NAMING_ERROR_MESSAGES.USE_CASE_START_VERB,
fileName,
`Start with a verb like: ${USE_CASE_VERBS.slice(0, 5).join(", ")}`,
),
)
}
}
return violations
}
private checkInfrastructureLayer(fileName: string, filePath: string): NamingViolation[] {
const violations: NamingViolation[] = []
if (fileName.endsWith(FILE_SUFFIXES.CONTROLLER)) {
if (!NAMING_PATTERNS.INFRASTRUCTURE.CONTROLLER.pattern.test(fileName)) {
violations.push(
NamingViolation.create(
fileName,
NAMING_VIOLATION_TYPES.WRONG_SUFFIX,
LAYERS.INFRASTRUCTURE,
filePath,
NAMING_PATTERNS.INFRASTRUCTURE.CONTROLLER.description,
fileName,
),
)
}
return violations
}
if (
fileName.endsWith(FILE_SUFFIXES.REPOSITORY) &&
!fileName.startsWith(PATTERN_WORDS.I_PREFIX)
) {
if (!NAMING_PATTERNS.INFRASTRUCTURE.REPOSITORY_IMPL.pattern.test(fileName)) {
violations.push(
NamingViolation.create(
fileName,
NAMING_VIOLATION_TYPES.WRONG_SUFFIX,
LAYERS.INFRASTRUCTURE,
filePath,
NAMING_PATTERNS.INFRASTRUCTURE.REPOSITORY_IMPL.description,
fileName,
),
)
}
return violations
}
if (fileName.endsWith(FILE_SUFFIXES.SERVICE) || fileName.endsWith(FILE_SUFFIXES.ADAPTER)) {
if (!NAMING_PATTERNS.INFRASTRUCTURE.SERVICE.pattern.test(fileName)) {
violations.push(
NamingViolation.create(
fileName,
NAMING_VIOLATION_TYPES.WRONG_SUFFIX,
LAYERS.INFRASTRUCTURE,
filePath,
NAMING_PATTERNS.INFRASTRUCTURE.SERVICE.description,
fileName,
),
)
}
return violations
}
return violations
}
private startsWithCommonVerb(fileName: string): boolean {
const baseFileName = fileName.replace(/\.tsx?$/, "")
return USE_CASE_VERBS.some((verb) => baseFileName.startsWith(verb))
}
}

View File

@@ -0,0 +1,91 @@
/**
* Default file scanning options
*/
export const DEFAULT_EXCLUDES = [
"node_modules",
"dist",
"build",
"coverage",
".git",
".puaros",
] as const
export const DEFAULT_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"] as const
/**
* Allowed numbers that are not considered magic numbers
*/
export const ALLOWED_NUMBERS = new Set([-1, 0, 1, 2, 10, 100, 1000])
/**
* Default context extraction size (characters)
*/
export const CONTEXT_EXTRACT_SIZE = 30
/**
* String length threshold for magic string detection
*/
export const MIN_STRING_LENGTH = 3
/**
* Single character limit for string detection
*/
export const SINGLE_CHAR_LIMIT = 1
/**
* Git defaults
*/
export const GIT_DEFAULTS = {
REMOTE: "origin",
BRANCH: "main",
} as const
/**
* Tree-sitter node types for function detection
*/
export const TREE_SITTER_NODE_TYPES = {
FUNCTION_DECLARATION: "function_declaration",
ARROW_FUNCTION: "arrow_function",
FUNCTION_EXPRESSION: "function_expression",
} as const
/**
* Detection keywords for hardcode analysis
*/
export const DETECTION_KEYWORDS = {
TIMEOUT: "timeout",
DELAY: "delay",
RETRY: "retry",
LIMIT: "limit",
MAX: "max",
MIN: "min",
PORT: "port",
INTERVAL: "interval",
TEST: "test",
DESCRIBE: "describe",
CONSOLE_LOG: "console.log",
CONSOLE_ERROR: "console.error",
HTTP: "http",
API: "api",
} as const
/**
* Code patterns for detecting exported constants
*/
export const CODE_PATTERNS = {
EXPORT_CONST: "export const ",
EXPORT: "export ",
IMPORT: "import ",
AS_CONST: " as const",
AS_CONST_OBJECT: "} as const",
AS_CONST_ARRAY: "] as const",
AS_CONST_END_SEMICOLON_OBJECT: "};",
AS_CONST_END_SEMICOLON_ARRAY: "];",
OBJECT_START: "= {",
ARRAY_START: "= [",
} as const
/**
* File encoding
*/
export const FILE_ENCODING = "utf-8" as const

View File

@@ -0,0 +1,66 @@
/**
* Naming Convention Detector Constants
*
* Following Clean Code principles:
* - No magic strings
* - Single source of truth
* - Easy to maintain
*/
/**
* Files to exclude from naming convention checks
*/
export const EXCLUDED_FILES = [
"index.ts",
"BaseUseCase.ts",
"BaseMapper.ts",
"IBaseRepository.ts",
"BaseEntity.ts",
"ValueObject.ts",
"BaseRepository.ts",
"BaseError.ts",
"DomainEvent.ts",
"Suggestions.ts",
] as const
/**
* File suffixes for pattern matching
*/
export const FILE_SUFFIXES = {
SERVICE: "Service.ts",
DTO: "Dto.ts",
REQUEST: "Request.ts",
RESPONSE: "Response.ts",
MAPPER: "Mapper.ts",
CONTROLLER: "Controller.ts",
REPOSITORY: "Repository.ts",
ADAPTER: "Adapter.ts",
} as const
/**
* Path patterns for detection
*/
export const PATH_PATTERNS = {
USE_CASES: "/use-cases/",
USE_CASES_ALT: "/usecases/",
} as const
/**
* Common words for pattern matching
*/
export const PATTERN_WORDS = {
REPOSITORY: "Repository",
I_PREFIX: "I",
} as const
/**
* Error messages for naming violations
*/
export const NAMING_ERROR_MESSAGES = {
DOMAIN_FORBIDDEN:
"Domain layer should not contain DTOs, Controllers, or Request/Response objects",
USE_PASCAL_CASE: "Use PascalCase noun (e.g., User.ts, Order.ts, Email.ts)",
USE_DTO_SUFFIX: "Use *Dto, *Request, or *Response suffix (e.g., UserResponseDto.ts)",
USE_VERB_NOUN: "Use verb + noun in PascalCase (e.g., CreateUser.ts, UpdateProfile.ts)",
USE_CASE_START_VERB: "Use cases should start with a verb",
} as const

View File

@@ -0,0 +1,3 @@
export * from "./parsers/CodeParser"
export * from "./scanners/FileScanner"
export * from "./analyzers/HardcodeDetector"

View File

@@ -0,0 +1,58 @@
import Parser from "tree-sitter"
import JavaScript from "tree-sitter-javascript"
import TypeScript from "tree-sitter-typescript"
import { ICodeParser } from "../../domain/services/ICodeParser"
import { TREE_SITTER_NODE_TYPES } from "../constants/defaults"
/**
* Code parser service using tree-sitter
*/
export class CodeParser implements ICodeParser {
private readonly parser: Parser
constructor() {
this.parser = new Parser()
}
public parseJavaScript(code: string): Parser.Tree {
this.parser.setLanguage(JavaScript)
return this.parser.parse(code)
}
public parseTypeScript(code: string): Parser.Tree {
this.parser.setLanguage(TypeScript.typescript)
return this.parser.parse(code)
}
public parseTsx(code: string): Parser.Tree {
this.parser.setLanguage(TypeScript.tsx)
return this.parser.parse(code)
}
public extractFunctions(tree: Parser.Tree): string[] {
const functions: string[] = []
const cursor = tree.walk()
const visit = (): void => {
const node = cursor.currentNode
if (
node.type === TREE_SITTER_NODE_TYPES.FUNCTION_DECLARATION ||
node.type === TREE_SITTER_NODE_TYPES.ARROW_FUNCTION ||
node.type === TREE_SITTER_NODE_TYPES.FUNCTION_EXPRESSION
) {
functions.push(node.text)
}
if (cursor.gotoFirstChild()) {
do {
visit()
} while (cursor.gotoNextSibling())
cursor.gotoParent()
}
}
visit()
return functions
}
}

View File

@@ -0,0 +1,69 @@
import * as fs from "fs/promises"
import * as path from "path"
import { FileScanOptions, IFileScanner } from "../../domain/services/IFileScanner"
import { DEFAULT_EXCLUDES, DEFAULT_EXTENSIONS, FILE_ENCODING } from "../constants/defaults"
import { ERROR_MESSAGES } from "../../shared/constants"
/**
* Scans project directory for source files
*/
export class FileScanner implements IFileScanner {
private readonly defaultExcludes = [...DEFAULT_EXCLUDES]
private readonly defaultExtensions = [...DEFAULT_EXTENSIONS]
public async scan(options: FileScanOptions): Promise<string[]> {
const {
rootDir,
exclude = this.defaultExcludes,
extensions = this.defaultExtensions,
} = options
return this.scanDirectory(rootDir, exclude, extensions)
}
private async scanDirectory(
dir: string,
exclude: string[],
extensions: string[],
): Promise<string[]> {
const files: string[] = []
try {
const entries = await fs.readdir(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (this.shouldExclude(entry.name, exclude)) {
continue
}
if (entry.isDirectory()) {
const subFiles = await this.scanDirectory(fullPath, exclude, extensions)
files.push(...subFiles)
} else if (entry.isFile()) {
const ext = path.extname(entry.name)
if (extensions.includes(ext)) {
files.push(fullPath)
}
}
}
} catch (error) {
throw new Error(`${ERROR_MESSAGES.FAILED_TO_SCAN_DIR} ${dir}: ${String(error)}`)
}
return files
}
private shouldExclude(name: string, excludePatterns: string[]): boolean {
return excludePatterns.some((pattern) => name.includes(pattern))
}
public async readFile(filePath: string): Promise<string> {
try {
return await fs.readFile(filePath, FILE_ENCODING)
} catch (error) {
throw new Error(`${ERROR_MESSAGES.FAILED_TO_READ_FILE} ${filePath}: ${String(error)}`)
}
}
}