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,90 @@
import {
AnalyzeProject,
AnalyzeProjectRequest,
AnalyzeProjectResponse,
} from "./application/use-cases/AnalyzeProject"
import { IFileScanner } from "./domain/services/IFileScanner"
import { ICodeParser } from "./domain/services/ICodeParser"
import { IHardcodeDetector } from "./domain/services/IHardcodeDetector"
import { INamingConventionDetector } from "./domain/services/INamingConventionDetector"
import { FileScanner } from "./infrastructure/scanners/FileScanner"
import { CodeParser } from "./infrastructure/parsers/CodeParser"
import { HardcodeDetector } from "./infrastructure/analyzers/HardcodeDetector"
import { NamingConventionDetector } from "./infrastructure/analyzers/NamingConventionDetector"
import { ERROR_MESSAGES } from "./shared/constants"
/**
* Analyzes a TypeScript/JavaScript project for code quality issues
*
* Detects hardcoded values (magic numbers and strings) and validates
* Clean Architecture layer dependencies.
*
* @param options - Configuration for the analysis
* @param options.rootDir - Root directory to analyze
* @param options.include - File patterns to include (optional)
* @param options.exclude - Directories to exclude (optional, defaults to node_modules, dist, build)
*
* @returns Analysis results including violations, metrics, and dependency graph
*
* @throws {Error} If analysis fails or project cannot be scanned
*
* @example
* ```typescript
* import { analyzeProject } from '@puaros/guardian'
*
* const result = await analyzeProject({
* rootDir: './src',
* exclude: ['node_modules', 'dist', 'test']
* })
*
* console.log(`Found ${result.hardcodeViolations.length} hardcoded values`)
* console.log(`Found ${result.violations.length} architecture violations`)
* console.log(`Analyzed ${result.metrics.totalFiles} files`)
* ```
*
* @example
* ```typescript
* // Check for hardcoded values only
* const result = await analyzeProject({ rootDir: './src' })
*
* result.hardcodeViolations.forEach(violation => {
* console.log(`${violation.file}:${violation.line}`)
* console.log(` Type: ${violation.type}`)
* console.log(` Value: ${violation.value}`)
* console.log(` Suggestion: ${violation.suggestion.constantName}`)
* console.log(` Location: ${violation.suggestion.location}`)
* })
* ```
*/
export async function analyzeProject(
options: AnalyzeProjectRequest,
): Promise<AnalyzeProjectResponse> {
const fileScanner: IFileScanner = new FileScanner()
const codeParser: ICodeParser = new CodeParser()
const hardcodeDetector: IHardcodeDetector = new HardcodeDetector()
const namingConventionDetector: INamingConventionDetector = new NamingConventionDetector()
const useCase = new AnalyzeProject(
fileScanner,
codeParser,
hardcodeDetector,
namingConventionDetector,
)
const result = await useCase.execute(options)
if (!result.success || !result.data) {
throw new Error(result.error ?? ERROR_MESSAGES.FAILED_TO_ANALYZE)
}
return result.data
}
export type {
AnalyzeProjectRequest,
AnalyzeProjectResponse,
ArchitectureViolation,
HardcodeViolation,
CircularDependencyViolation,
NamingConventionViolation,
ProjectMetrics,
} from "./application/use-cases/AnalyzeProject"

View File

@@ -0,0 +1,31 @@
/**
* Standard response wrapper for use cases
*/
export interface IResponseDto<T> {
success: boolean
data?: T
error?: string
timestamp: Date
}
export class ResponseDto<T> implements IResponseDto<T> {
public readonly success: boolean
public readonly data?: T
public readonly error?: string
public readonly timestamp: Date
private constructor(success: boolean, data?: T, error?: string) {
this.success = success
this.data = data
this.error = error
this.timestamp = new Date()
}
public static ok<T>(data: T): ResponseDto<T> {
return new ResponseDto<T>(true, data)
}
public static fail<T>(error: string): ResponseDto<T> {
return new ResponseDto<T>(false, undefined, error)
}
}

View File

@@ -0,0 +1,4 @@
export * from "./use-cases/BaseUseCase"
export * from "./use-cases/AnalyzeProject"
export * from "./dtos/ResponseDto"
export * from "./mappers/BaseMapper"

View File

@@ -0,0 +1,20 @@
/**
* Generic mapper interface for converting between domain entities and DTOs
*/
export interface IMapper<TDomain, TDto> {
toDto(domain: TDomain): TDto
toDomain(dto: TDto): TDomain
}
export abstract class Mapper<TDomain, TDto> implements IMapper<TDomain, TDto> {
public abstract toDto(domain: TDomain): TDto
public abstract toDomain(dto: TDto): TDomain
public toDtoList(domains: TDomain[]): TDto[] {
return domains.map((domain) => this.toDto(domain))
}
public toDomainList(dtos: TDto[]): TDomain[] {
return dtos.map((dto) => this.toDomain(dto))
}
}

View File

@@ -0,0 +1,344 @@
import { UseCase } from "./BaseUseCase"
import { ResponseDto } from "../dtos/ResponseDto"
import { IFileScanner } from "../../domain/services/IFileScanner"
import { ICodeParser } from "../../domain/services/ICodeParser"
import { IHardcodeDetector } from "../../domain/services/IHardcodeDetector"
import { INamingConventionDetector } from "../../domain/services/INamingConventionDetector"
import { SourceFile } from "../../domain/entities/SourceFile"
import { DependencyGraph } from "../../domain/entities/DependencyGraph"
import { ProjectPath } from "../../domain/value-objects/ProjectPath"
import {
ERROR_MESSAGES,
HARDCODE_TYPES,
LAYERS,
NAMING_VIOLATION_TYPES,
REGEX_PATTERNS,
RULES,
SEVERITY_LEVELS,
} from "../../shared/constants"
export interface AnalyzeProjectRequest {
rootDir: string
include?: string[]
exclude?: string[]
}
export interface AnalyzeProjectResponse {
files: SourceFile[]
dependencyGraph: DependencyGraph
violations: ArchitectureViolation[]
hardcodeViolations: HardcodeViolation[]
circularDependencyViolations: CircularDependencyViolation[]
namingViolations: NamingConventionViolation[]
metrics: ProjectMetrics
}
export interface ArchitectureViolation {
rule: string
message: string
file: string
line?: number
}
export interface HardcodeViolation {
rule: typeof RULES.HARDCODED_VALUE
type:
| typeof HARDCODE_TYPES.MAGIC_NUMBER
| typeof HARDCODE_TYPES.MAGIC_STRING
| typeof HARDCODE_TYPES.MAGIC_CONFIG
value: string | number
file: string
line: number
column: number
context: string
suggestion: {
constantName: string
location: string
}
}
export interface CircularDependencyViolation {
rule: typeof RULES.CIRCULAR_DEPENDENCY
message: string
cycle: string[]
severity: typeof SEVERITY_LEVELS.ERROR
}
export interface NamingConventionViolation {
rule: typeof RULES.NAMING_CONVENTION
type:
| typeof NAMING_VIOLATION_TYPES.WRONG_SUFFIX
| typeof NAMING_VIOLATION_TYPES.WRONG_PREFIX
| typeof NAMING_VIOLATION_TYPES.WRONG_CASE
| typeof NAMING_VIOLATION_TYPES.FORBIDDEN_PATTERN
| typeof NAMING_VIOLATION_TYPES.WRONG_VERB_NOUN
fileName: string
layer: string
file: string
expected: string
actual: string
message: string
suggestion?: string
}
export interface ProjectMetrics {
totalFiles: number
totalFunctions: number
totalImports: number
layerDistribution: Record<string, number>
}
/**
* Main use case for analyzing a project's codebase
*/
export class AnalyzeProject extends UseCase<
AnalyzeProjectRequest,
ResponseDto<AnalyzeProjectResponse>
> {
constructor(
private readonly fileScanner: IFileScanner,
private readonly codeParser: ICodeParser,
private readonly hardcodeDetector: IHardcodeDetector,
private readonly namingConventionDetector: INamingConventionDetector,
) {
super()
}
public async execute(
request: AnalyzeProjectRequest,
): Promise<ResponseDto<AnalyzeProjectResponse>> {
try {
const filePaths = await this.fileScanner.scan({
rootDir: request.rootDir,
include: request.include,
exclude: request.exclude,
})
const sourceFiles: SourceFile[] = []
const dependencyGraph = new DependencyGraph()
let totalFunctions = 0
for (const filePath of filePaths) {
const content = await this.fileScanner.readFile(filePath)
const projectPath = ProjectPath.create(filePath, request.rootDir)
const imports = this.extractImports(content)
const exports = this.extractExports(content)
const sourceFile = new SourceFile(projectPath, content, imports, exports)
sourceFiles.push(sourceFile)
dependencyGraph.addFile(sourceFile)
if (projectPath.isTypeScript()) {
const tree = this.codeParser.parseTypeScript(content)
const functions = this.codeParser.extractFunctions(tree)
totalFunctions += functions.length
}
for (const imp of imports) {
dependencyGraph.addDependency(
projectPath.relative,
this.resolveImportPath(imp, filePath, request.rootDir),
)
}
}
const violations = this.detectViolations(sourceFiles)
const hardcodeViolations = this.detectHardcode(sourceFiles)
const circularDependencyViolations = this.detectCircularDependencies(dependencyGraph)
const namingViolations = this.detectNamingConventions(sourceFiles)
const metrics = this.calculateMetrics(sourceFiles, totalFunctions, dependencyGraph)
return ResponseDto.ok({
files: sourceFiles,
dependencyGraph,
violations,
hardcodeViolations,
circularDependencyViolations,
namingViolations,
metrics,
})
} catch (error) {
const errorMessage = `${ERROR_MESSAGES.FAILED_TO_ANALYZE}: ${error instanceof Error ? error.message : String(error)}`
return ResponseDto.fail(errorMessage)
}
}
private extractImports(content: string): string[] {
const imports: string[] = []
let match
while ((match = REGEX_PATTERNS.IMPORT_STATEMENT.exec(content)) !== null) {
imports.push(match[1])
}
return imports
}
private extractExports(content: string): string[] {
const exports: string[] = []
let match
while ((match = REGEX_PATTERNS.EXPORT_STATEMENT.exec(content)) !== null) {
exports.push(match[1])
}
return exports
}
private resolveImportPath(importPath: string, _currentFile: string, _rootDir: string): string {
if (importPath.startsWith(".")) {
return importPath
}
return importPath
}
private detectViolations(sourceFiles: SourceFile[]): ArchitectureViolation[] {
const violations: ArchitectureViolation[] = []
const layerRules: Record<string, string[]> = {
[LAYERS.DOMAIN]: [LAYERS.SHARED],
[LAYERS.APPLICATION]: [LAYERS.DOMAIN, LAYERS.SHARED],
[LAYERS.INFRASTRUCTURE]: [LAYERS.DOMAIN, LAYERS.APPLICATION, LAYERS.SHARED],
[LAYERS.SHARED]: [],
}
for (const file of sourceFiles) {
if (!file.layer) {
continue
}
const allowedLayers = layerRules[file.layer]
for (const imp of file.imports) {
const importedLayer = this.detectLayerFromImport(imp)
if (
importedLayer &&
importedLayer !== file.layer &&
!allowedLayers.includes(importedLayer)
) {
violations.push({
rule: RULES.CLEAN_ARCHITECTURE,
message: `Layer "${file.layer}" cannot import from "${importedLayer}"`,
file: file.path.relative,
})
}
}
}
return violations
}
private detectLayerFromImport(importPath: string): string | undefined {
const layers = Object.values(LAYERS)
for (const layer of layers) {
if (importPath.toLowerCase().includes(layer)) {
return layer
}
}
return undefined
}
private detectHardcode(sourceFiles: SourceFile[]): HardcodeViolation[] {
const violations: HardcodeViolation[] = []
for (const file of sourceFiles) {
const hardcodedValues = this.hardcodeDetector.detectAll(
file.content,
file.path.relative,
)
for (const hardcoded of hardcodedValues) {
violations.push({
rule: RULES.HARDCODED_VALUE,
type: hardcoded.type,
value: hardcoded.value,
file: file.path.relative,
line: hardcoded.line,
column: hardcoded.column,
context: hardcoded.context,
suggestion: {
constantName: hardcoded.suggestConstantName(),
location: hardcoded.suggestLocation(file.layer),
},
})
}
}
return violations
}
private detectCircularDependencies(
dependencyGraph: DependencyGraph,
): CircularDependencyViolation[] {
const violations: CircularDependencyViolation[] = []
const cycles = dependencyGraph.findCycles()
for (const cycle of cycles) {
const cycleChain = [...cycle, cycle[0]].join(" → ")
violations.push({
rule: RULES.CIRCULAR_DEPENDENCY,
message: `Circular dependency detected: ${cycleChain}`,
cycle,
severity: SEVERITY_LEVELS.ERROR,
})
}
return violations
}
private detectNamingConventions(sourceFiles: SourceFile[]): NamingConventionViolation[] {
const violations: NamingConventionViolation[] = []
for (const file of sourceFiles) {
const namingViolations = this.namingConventionDetector.detectViolations(
file.path.filename,
file.layer,
file.path.relative,
)
for (const violation of namingViolations) {
violations.push({
rule: RULES.NAMING_CONVENTION,
type: violation.violationType,
fileName: violation.fileName,
layer: violation.layer,
file: violation.filePath,
expected: violation.expected,
actual: violation.actual,
message: violation.getMessage(),
suggestion: violation.suggestion,
})
}
}
return violations
}
private calculateMetrics(
sourceFiles: SourceFile[],
totalFunctions: number,
_dependencyGraph: DependencyGraph,
): ProjectMetrics {
const layerDistribution: Record<string, number> = {}
let totalImports = 0
for (const file of sourceFiles) {
if (file.layer) {
layerDistribution[file.layer] = (layerDistribution[file.layer] || 0) + 1
}
totalImports += file.imports.length
}
return {
totalFiles: sourceFiles.length,
totalFunctions,
totalImports,
layerDistribution,
}
}
}

View File

@@ -0,0 +1,13 @@
/**
* Base interface for all use cases
*/
export interface IUseCase<TRequest, TResponse> {
execute(request: TRequest): Promise<TResponse>
}
/**
* Abstract base class for use cases
*/
export abstract class UseCase<TRequest, TResponse> implements IUseCase<TRequest, TResponse> {
public abstract execute(request: TRequest): Promise<TResponse>
}

View File

@@ -0,0 +1,63 @@
/**
* CLI Constants
*
* Following Clean Code principles:
* - No magic strings
* - Single source of truth
* - Easy to maintain and translate
*/
export const CLI_COMMANDS = {
NAME: "guardian",
CHECK: "check",
} as const
export const CLI_DESCRIPTIONS = {
MAIN: "🛡️ Code quality guardian - detect hardcoded values and architecture violations",
CHECK: "Analyze project for code quality issues",
PATH_ARG: "Path to analyze",
EXCLUDE_OPTION: "Directories to exclude",
VERBOSE_OPTION: "Verbose output",
NO_HARDCODE_OPTION: "Skip hardcode detection",
NO_ARCHITECTURE_OPTION: "Skip architecture checks",
} as const
export const CLI_OPTIONS = {
EXCLUDE: "-e, --exclude <dirs...>",
VERBOSE: "-v, --verbose",
NO_HARDCODE: "--no-hardcode",
NO_ARCHITECTURE: "--no-architecture",
} as const
export const CLI_ARGUMENTS = {
PATH: "<path>",
} as const
export const DEFAULT_EXCLUDES = ["node_modules", "dist", "build", "coverage"] as const
export const CLI_MESSAGES = {
ANALYZING: "\n🛡 Guardian - Analyzing your code...\n",
METRICS_HEADER: "📊 Project Metrics:",
LAYER_DISTRIBUTION_HEADER: "\n📦 Layer Distribution:",
VIOLATIONS_HEADER: "\n⚠ Found",
CIRCULAR_DEPS_HEADER: "\n🔄 Found",
NAMING_VIOLATIONS_HEADER: "\n📝 Found",
HARDCODE_VIOLATIONS_HEADER: "\n🔍 Found",
NO_ISSUES: "\n✅ No issues found! Your code looks great!",
ISSUES_TOTAL: "\n❌ Found",
TIP: "\n💡 Tip: Fix these issues to improve code quality and maintainability.\n",
HELP_FOOTER: "\nRun with --help for more options",
ERROR_PREFIX: "Error analyzing project:",
} as const
export const CLI_LABELS = {
FILES_ANALYZED: "Files analyzed:",
TOTAL_FUNCTIONS: "Total functions:",
TOTAL_IMPORTS: "Total imports:",
FILES: "files",
ARCHITECTURE_VIOLATIONS: "architecture violations:",
CIRCULAR_DEPENDENCIES: "circular dependencies:",
NAMING_VIOLATIONS: "naming convention violations:",
HARDCODE_VIOLATIONS: "hardcoded values:",
ISSUES_TOTAL: "issues total",
} as const

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env node
import { Command } from "commander"
import { analyzeProject } from "../api"
import { version } from "../../package.json"
import {
CLI_COMMANDS,
CLI_DESCRIPTIONS,
CLI_OPTIONS,
CLI_ARGUMENTS,
DEFAULT_EXCLUDES,
CLI_MESSAGES,
CLI_LABELS,
} from "./constants"
const program = new Command()
program.name(CLI_COMMANDS.NAME).description(CLI_DESCRIPTIONS.MAIN).version(version)
program
.command(CLI_COMMANDS.CHECK)
.description(CLI_DESCRIPTIONS.CHECK)
.argument(CLI_ARGUMENTS.PATH, CLI_DESCRIPTIONS.PATH_ARG)
.option(CLI_OPTIONS.EXCLUDE, CLI_DESCRIPTIONS.EXCLUDE_OPTION, [...DEFAULT_EXCLUDES])
.option(CLI_OPTIONS.VERBOSE, CLI_DESCRIPTIONS.VERBOSE_OPTION, false)
.option(CLI_OPTIONS.NO_HARDCODE, CLI_DESCRIPTIONS.NO_HARDCODE_OPTION)
.option(CLI_OPTIONS.NO_ARCHITECTURE, CLI_DESCRIPTIONS.NO_ARCHITECTURE_OPTION)
.action(async (path: string, options) => {
try {
console.log(CLI_MESSAGES.ANALYZING)
const result = await analyzeProject({
rootDir: path,
exclude: options.exclude,
})
const {
hardcodeViolations,
violations,
circularDependencyViolations,
namingViolations,
metrics,
} = result
// Display metrics
console.log(CLI_MESSAGES.METRICS_HEADER)
console.log(` ${CLI_LABELS.FILES_ANALYZED} ${String(metrics.totalFiles)}`)
console.log(` ${CLI_LABELS.TOTAL_FUNCTIONS} ${String(metrics.totalFunctions)}`)
console.log(` ${CLI_LABELS.TOTAL_IMPORTS} ${String(metrics.totalImports)}`)
if (Object.keys(metrics.layerDistribution).length > 0) {
console.log(CLI_MESSAGES.LAYER_DISTRIBUTION_HEADER)
for (const [layer, count] of Object.entries(metrics.layerDistribution)) {
console.log(` ${layer}: ${String(count)} ${CLI_LABELS.FILES}`)
}
}
// Architecture violations
if (options.architecture && violations.length > 0) {
console.log(
`${CLI_MESSAGES.VIOLATIONS_HEADER} ${String(violations.length)} ${CLI_LABELS.ARCHITECTURE_VIOLATIONS}\n`,
)
violations.forEach((v, index) => {
console.log(`${String(index + 1)}. ${v.file}`)
console.log(` Rule: ${v.rule}`)
console.log(` ${v.message}`)
console.log("")
})
}
// Circular dependency violations
if (options.architecture && circularDependencyViolations.length > 0) {
console.log(
`${CLI_MESSAGES.CIRCULAR_DEPS_HEADER} ${String(circularDependencyViolations.length)} ${CLI_LABELS.CIRCULAR_DEPENDENCIES}\n`,
)
circularDependencyViolations.forEach((cd, index) => {
console.log(`${String(index + 1)}. ${cd.message}`)
console.log(` Severity: ${cd.severity}`)
console.log(` Cycle path:`)
cd.cycle.forEach((file, i) => {
console.log(` ${String(i + 1)}. ${file}`)
})
console.log(
` ${String(cd.cycle.length + 1)}. ${cd.cycle[0]} (back to start)`,
)
console.log("")
})
}
// Naming convention violations
if (options.architecture && namingViolations.length > 0) {
console.log(
`${CLI_MESSAGES.NAMING_VIOLATIONS_HEADER} ${String(namingViolations.length)} ${CLI_LABELS.NAMING_VIOLATIONS}\n`,
)
namingViolations.forEach((nc, index) => {
console.log(`${String(index + 1)}. ${nc.file}`)
console.log(` File: ${nc.fileName}`)
console.log(` Layer: ${nc.layer}`)
console.log(` Type: ${nc.type}`)
console.log(` Message: ${nc.message}`)
if (nc.suggestion) {
console.log(` 💡 Suggestion: ${nc.suggestion}`)
}
console.log("")
})
}
// Hardcode violations
if (options.hardcode && hardcodeViolations.length > 0) {
console.log(
`${CLI_MESSAGES.HARDCODE_VIOLATIONS_HEADER} ${String(hardcodeViolations.length)} ${CLI_LABELS.HARDCODE_VIOLATIONS}\n`,
)
hardcodeViolations.forEach((hc, index) => {
console.log(
`${String(index + 1)}. ${hc.file}:${String(hc.line)}:${String(hc.column)}`,
)
console.log(` Type: ${hc.type}`)
console.log(` Value: ${JSON.stringify(hc.value)}`)
console.log(` Context: ${hc.context.trim()}`)
console.log(` 💡 Suggested: ${hc.suggestion.constantName}`)
console.log(` 📁 Location: ${hc.suggestion.location}`)
console.log("")
})
}
// Summary
const totalIssues =
violations.length +
hardcodeViolations.length +
circularDependencyViolations.length +
namingViolations.length
if (totalIssues === 0) {
console.log(CLI_MESSAGES.NO_ISSUES)
process.exit(0)
} else {
console.log(
`${CLI_MESSAGES.ISSUES_TOTAL} ${String(totalIssues)} ${CLI_LABELS.ISSUES_TOTAL}`,
)
console.log(CLI_MESSAGES.TIP)
if (options.verbose) {
console.log(CLI_MESSAGES.HELP_FOOTER)
}
process.exit(1)
}
} catch (error) {
console.error(`\n❌ ${CLI_MESSAGES.ERROR_PREFIX}`)
console.error(error instanceof Error ? error.message : String(error))
console.error("")
process.exit(1)
}
})
program.parse()

View File

@@ -0,0 +1,53 @@
/**
* Suggestion keywords for hardcode detection
*/
export const SUGGESTION_KEYWORDS = {
TIMEOUT: "timeout",
RETRY: "retry",
ATTEMPT: "attempt",
LIMIT: "limit",
MAX: "max",
PORT: "port",
DELAY: "delay",
ERROR: "error",
MESSAGE: "message",
DEFAULT: "default",
ENTITY: "entity",
AGGREGATE: "aggregate",
DOMAIN: "domain",
CONFIG: "config",
ENV: "env",
HTTP: "http",
TEST: "test",
DESCRIBE: "describe",
CONSOLE_LOG: "console.log",
CONSOLE_ERROR: "console.error",
} as const
/**
* Constant name templates
*/
export const CONSTANT_NAMES = {
TIMEOUT_MS: "TIMEOUT_MS",
MAX_RETRIES: "MAX_RETRIES",
MAX_LIMIT: "MAX_LIMIT",
DEFAULT_PORT: "DEFAULT_PORT",
DELAY_MS: "DELAY_MS",
API_BASE_URL: "API_BASE_URL",
DEFAULT_PATH: "DEFAULT_PATH",
DEFAULT_DOMAIN: "DEFAULT_DOMAIN",
ERROR_MESSAGE: "ERROR_MESSAGE",
DEFAULT_VALUE: "DEFAULT_VALUE",
MAGIC_STRING: "MAGIC_STRING",
MAGIC_NUMBER: "MAGIC_NUMBER",
UNKNOWN_CONSTANT: "UNKNOWN_CONSTANT",
} as const
/**
* Location suggestions
*/
export const LOCATIONS = {
SHARED_CONSTANTS: "shared/constants",
DOMAIN_CONSTANTS: "domain/constants",
INFRASTRUCTURE_CONFIG: "infrastructure/config",
} as const

View File

@@ -0,0 +1,44 @@
import { v4 as uuidv4 } from "uuid"
/**
* Base entity class with ID and timestamps
*/
export abstract class BaseEntity {
protected readonly _id: string
protected readonly _createdAt: Date
protected _updatedAt: Date
constructor(id?: string) {
this._id = id ?? uuidv4()
this._createdAt = new Date()
this._updatedAt = new Date()
}
public get id(): string {
return this._id
}
public get createdAt(): Date {
return this._createdAt
}
public get updatedAt(): Date {
return this._updatedAt
}
protected touch(): void {
this._updatedAt = new Date()
}
public equals(entity?: BaseEntity): boolean {
if (!entity) {
return false
}
if (this === entity) {
return true
}
return this._id === entity._id
}
}

View File

@@ -0,0 +1,109 @@
import { BaseEntity } from "./BaseEntity"
import { SourceFile } from "./SourceFile"
interface GraphNode {
file: SourceFile
dependencies: string[]
dependents: string[]
}
/**
* Represents dependency graph of the analyzed project
*/
export class DependencyGraph extends BaseEntity {
private readonly nodes: Map<string, GraphNode>
constructor(id?: string) {
super(id)
this.nodes = new Map()
}
public addFile(file: SourceFile): void {
const fileId = file.path.relative
if (!this.nodes.has(fileId)) {
this.nodes.set(fileId, {
file,
dependencies: [],
dependents: [],
})
}
this.touch()
}
public addDependency(from: string, to: string): void {
const fromNode = this.nodes.get(from)
const toNode = this.nodes.get(to)
if (fromNode && toNode) {
if (!fromNode.dependencies.includes(to)) {
fromNode.dependencies.push(to)
}
if (!toNode.dependents.includes(from)) {
toNode.dependents.push(from)
}
this.touch()
}
}
public getNode(filePath: string): GraphNode | undefined {
return this.nodes.get(filePath)
}
public getAllNodes(): GraphNode[] {
return Array.from(this.nodes.values())
}
public findCycles(): string[][] {
const cycles: string[][] = []
const visited = new Set<string>()
const recursionStack = new Set<string>()
const dfs = (nodeId: string, path: string[]): void => {
visited.add(nodeId)
recursionStack.add(nodeId)
path.push(nodeId)
const node = this.nodes.get(nodeId)
if (node) {
for (const dep of node.dependencies) {
if (!visited.has(dep)) {
dfs(dep, [...path])
} else if (recursionStack.has(dep)) {
const cycleStart = path.indexOf(dep)
cycles.push(path.slice(cycleStart))
}
}
}
recursionStack.delete(nodeId)
}
for (const nodeId of this.nodes.keys()) {
if (!visited.has(nodeId)) {
dfs(nodeId, [])
}
}
return cycles
}
public getMetrics(): {
totalFiles: number
totalDependencies: number
avgDependencies: number
maxDependencies: number
} {
const nodes = Array.from(this.nodes.values())
const totalFiles = nodes.length
const totalDependencies = nodes.reduce((sum, node) => sum + node.dependencies.length, 0)
return {
totalFiles,
totalDependencies,
avgDependencies: totalFiles > 0 ? totalDependencies / totalFiles : 0,
maxDependencies: Math.max(...nodes.map((node) => node.dependencies.length), 0),
}
}
}

View File

@@ -0,0 +1,86 @@
import { BaseEntity } from "./BaseEntity"
import { ProjectPath } from "../value-objects/ProjectPath"
import { LAYERS } from "../../shared/constants/rules"
/**
* Represents a source code file in the analyzed project
*/
export class SourceFile extends BaseEntity {
private readonly _path: ProjectPath
private readonly _content: string
private readonly _imports: string[]
private readonly _exports: string[]
private readonly _layer?: string
constructor(
path: ProjectPath,
content: string,
imports: string[] = [],
exports: string[] = [],
id?: string,
) {
super(id)
this._path = path
this._content = content
this._imports = imports
this._exports = exports
this._layer = this.detectLayer()
}
public get path(): ProjectPath {
return this._path
}
public get content(): string {
return this._content
}
public get imports(): string[] {
return [...this._imports]
}
public get exports(): string[] {
return [...this._exports]
}
public get layer(): string | undefined {
return this._layer
}
public addImport(importPath: string): void {
if (!this._imports.includes(importPath)) {
this._imports.push(importPath)
this.touch()
}
}
public addExport(exportName: string): void {
if (!this._exports.includes(exportName)) {
this._exports.push(exportName)
this.touch()
}
}
private detectLayer(): string | undefined {
const dir = this._path.directory.toLowerCase()
if (dir.includes(LAYERS.DOMAIN)) {
return LAYERS.DOMAIN
}
if (dir.includes(LAYERS.APPLICATION)) {
return LAYERS.APPLICATION
}
if (dir.includes(LAYERS.INFRASTRUCTURE)) {
return LAYERS.INFRASTRUCTURE
}
if (dir.includes(LAYERS.SHARED)) {
return LAYERS.SHARED
}
return undefined
}
public importsFrom(layer: string): boolean {
return this._imports.some((imp) => imp.toLowerCase().includes(layer))
}
}

View File

@@ -0,0 +1,25 @@
import { v4 as uuidv4 } from "uuid"
/**
* Base interface for all domain events
*/
export interface IDomainEvent {
readonly eventId: string
readonly occurredOn: Date
readonly eventType: string
}
/**
* Base class for domain events
*/
export abstract class DomainEvent implements IDomainEvent {
public readonly eventId: string
public readonly occurredOn: Date
public readonly eventType: string
constructor(eventType: string) {
this.eventId = uuidv4()
this.occurredOn = new Date()
this.eventType = eventType
}
}

View File

@@ -0,0 +1,13 @@
export * from "./entities/BaseEntity"
export * from "./entities/SourceFile"
export * from "./entities/DependencyGraph"
export * from "./value-objects/ValueObject"
export * from "./value-objects/ProjectPath"
export * from "./value-objects/HardcodedValue"
export * from "./value-objects/NamingViolation"
export * from "./repositories/IBaseRepository"
export * from "./services/IFileScanner"
export * from "./services/ICodeParser"
export * from "./services/IHardcodeDetector"
export * from "./services/INamingConventionDetector"
export * from "./events/DomainEvent"

View File

@@ -0,0 +1,14 @@
import { BaseEntity } from "../entities/BaseEntity"
/**
* Generic repository interface
* Defines standard CRUD operations for entities
*/
export interface IRepository<T extends BaseEntity> {
findById(id: string): Promise<T | null>
findAll(): Promise<T[]>
save(entity: T): Promise<T>
update(entity: T): Promise<T>
delete(id: string): Promise<boolean>
exists(id: string): Promise<boolean>
}

View File

@@ -0,0 +1,10 @@
/**
* Interface for parsing source code
* Allows infrastructure implementations without domain coupling
*/
export interface ICodeParser {
parseJavaScript(code: string): unknown
parseTypeScript(code: string): unknown
parseTsx(code: string): unknown
extractFunctions(tree: unknown): string[]
}

View File

@@ -0,0 +1,15 @@
export interface FileScanOptions {
rootDir: string
include?: string[]
exclude?: string[]
extensions?: string[]
}
/**
* Interface for scanning project files
* Allows infrastructure implementations without domain coupling
*/
export interface IFileScanner {
scan(options: FileScanOptions): Promise<string[]>
readFile(filePath: string): Promise<string>
}

View File

@@ -0,0 +1,10 @@
import { HardcodedValue } from "../value-objects/HardcodedValue"
/**
* Interface for detecting hardcoded values in source code
*/
export interface IHardcodeDetector {
detectMagicNumbers(code: string, filePath: string): HardcodedValue[]
detectMagicStrings(code: string, filePath: string): HardcodedValue[]
detectAll(code: string, filePath: string): HardcodedValue[]
}

View File

@@ -0,0 +1,20 @@
import { NamingViolation } from "../value-objects/NamingViolation"
/**
* Interface for detecting naming convention violations in source files
*/
export interface INamingConventionDetector {
/**
* Detects naming convention violations for a given file
*
* @param fileName - Name of the file to check (e.g., "UserService.ts")
* @param layer - Architectural layer of the file (domain, application, infrastructure, shared)
* @param filePath - Relative file path for context
* @returns Array of naming convention violations
*/
detectViolations(
fileName: string,
layer: string | undefined,
filePath: string,
): NamingViolation[]
}

View File

@@ -0,0 +1,156 @@
import { ValueObject } from "./ValueObject"
import { HARDCODE_TYPES } from "../../shared/constants/rules"
import { CONSTANT_NAMES, LOCATIONS, SUGGESTION_KEYWORDS } from "../constants/Suggestions"
export type HardcodeType = (typeof HARDCODE_TYPES)[keyof typeof HARDCODE_TYPES]
interface HardcodedValueProps {
readonly value: string | number
readonly type: HardcodeType
readonly line: number
readonly column: number
readonly context: string
}
/**
* Represents a hardcoded value found in source code
*/
export class HardcodedValue extends ValueObject<HardcodedValueProps> {
private constructor(props: HardcodedValueProps) {
super(props)
}
public static create(
value: string | number,
type: HardcodeType,
line: number,
column: number,
context: string,
): HardcodedValue {
return new HardcodedValue({
value,
type,
line,
column,
context,
})
}
public get value(): string | number {
return this.props.value
}
public get type(): HardcodeType {
return this.props.type
}
public get line(): number {
return this.props.line
}
public get column(): number {
return this.props.column
}
public get context(): string {
return this.props.context
}
public isMagicNumber(): boolean {
return this.props.type === HARDCODE_TYPES.MAGIC_NUMBER
}
public isMagicString(): boolean {
return this.props.type === HARDCODE_TYPES.MAGIC_STRING
}
public suggestConstantName(): string {
if (this.isMagicNumber()) {
return this.suggestNumberConstantName()
}
if (this.isMagicString()) {
return this.suggestStringConstantName()
}
return CONSTANT_NAMES.UNKNOWN_CONSTANT
}
private suggestNumberConstantName(): string {
const value = this.props.value
const context = this.props.context.toLowerCase()
if (context.includes(SUGGESTION_KEYWORDS.TIMEOUT)) {
return CONSTANT_NAMES.TIMEOUT_MS
}
if (
context.includes(SUGGESTION_KEYWORDS.RETRY) ||
context.includes(SUGGESTION_KEYWORDS.ATTEMPT)
) {
return CONSTANT_NAMES.MAX_RETRIES
}
if (
context.includes(SUGGESTION_KEYWORDS.LIMIT) ||
context.includes(SUGGESTION_KEYWORDS.MAX)
) {
return CONSTANT_NAMES.MAX_LIMIT
}
if (context.includes(SUGGESTION_KEYWORDS.PORT)) {
return CONSTANT_NAMES.DEFAULT_PORT
}
if (context.includes(SUGGESTION_KEYWORDS.DELAY)) {
return CONSTANT_NAMES.DELAY_MS
}
return `${CONSTANT_NAMES.MAGIC_NUMBER}_${String(value)}`
}
private suggestStringConstantName(): string {
const value = String(this.props.value)
const context = this.props.context.toLowerCase()
if (value.includes(SUGGESTION_KEYWORDS.HTTP)) {
return CONSTANT_NAMES.API_BASE_URL
}
if (value.includes(".") && !value.includes(" ")) {
if (value.includes("/")) {
return CONSTANT_NAMES.DEFAULT_PATH
}
return CONSTANT_NAMES.DEFAULT_DOMAIN
}
if (
context.includes(SUGGESTION_KEYWORDS.ERROR) ||
context.includes(SUGGESTION_KEYWORDS.MESSAGE)
) {
return CONSTANT_NAMES.ERROR_MESSAGE
}
if (context.includes(SUGGESTION_KEYWORDS.DEFAULT)) {
return CONSTANT_NAMES.DEFAULT_VALUE
}
return CONSTANT_NAMES.MAGIC_STRING
}
public suggestLocation(currentLayer?: string): string {
if (!currentLayer) {
return LOCATIONS.SHARED_CONSTANTS
}
const context = this.props.context.toLowerCase()
if (
context.includes(SUGGESTION_KEYWORDS.ENTITY) ||
context.includes(SUGGESTION_KEYWORDS.AGGREGATE) ||
context.includes(SUGGESTION_KEYWORDS.DOMAIN)
) {
return currentLayer ? `${currentLayer}/constants` : LOCATIONS.DOMAIN_CONSTANTS
}
if (
context.includes(SUGGESTION_KEYWORDS.CONFIG) ||
context.includes(SUGGESTION_KEYWORDS.ENV)
) {
return LOCATIONS.INFRASTRUCTURE_CONFIG
}
return LOCATIONS.SHARED_CONSTANTS
}
}

View File

@@ -0,0 +1,82 @@
import { ValueObject } from "./ValueObject"
import { NAMING_VIOLATION_TYPES } from "../../shared/constants/rules"
export type NamingViolationType =
(typeof NAMING_VIOLATION_TYPES)[keyof typeof NAMING_VIOLATION_TYPES]
interface NamingViolationProps {
readonly fileName: string
readonly violationType: NamingViolationType
readonly layer: string
readonly filePath: string
readonly expected: string
readonly actual: string
readonly suggestion?: string
}
/**
* Represents a naming convention violation found in source code
*/
export class NamingViolation extends ValueObject<NamingViolationProps> {
private constructor(props: NamingViolationProps) {
super(props)
}
public static create(
fileName: string,
violationType: NamingViolationType,
layer: string,
filePath: string,
expected: string,
actual: string,
suggestion?: string,
): NamingViolation {
return new NamingViolation({
fileName,
violationType,
layer,
filePath,
expected,
actual,
suggestion,
})
}
public get fileName(): string {
return this.props.fileName
}
public get violationType(): NamingViolationType {
return this.props.violationType
}
public get layer(): string {
return this.props.layer
}
public get filePath(): string {
return this.props.filePath
}
public get expected(): string {
return this.props.expected
}
public get actual(): string {
return this.props.actual
}
public get suggestion(): string | undefined {
return this.props.suggestion
}
public getMessage(): string {
const baseMessage = `File "${this.fileName}" in "${this.layer}" layer violates naming convention`
if (this.suggestion) {
return `${baseMessage}. Expected: ${this.expected}. Suggestion: ${this.suggestion}`
}
return `${baseMessage}. Expected: ${this.expected}`
}
}

View File

@@ -0,0 +1,56 @@
import { ValueObject } from "./ValueObject"
import * as path from "path"
import { FILE_EXTENSIONS } from "../../shared/constants"
interface ProjectPathProps {
readonly absolutePath: string
readonly relativePath: string
}
/**
* Value object representing a file path in the analyzed project
*/
export class ProjectPath extends ValueObject<ProjectPathProps> {
private constructor(props: ProjectPathProps) {
super(props)
}
public static create(absolutePath: string, projectRoot: string): ProjectPath {
const relativePath = path.relative(projectRoot, absolutePath)
return new ProjectPath({ absolutePath, relativePath })
}
public get absolute(): string {
return this.props.absolutePath
}
public get relative(): string {
return this.props.relativePath
}
public get extension(): string {
return path.extname(this.props.absolutePath)
}
public get filename(): string {
return path.basename(this.props.absolutePath)
}
public get directory(): string {
return path.dirname(this.props.relativePath)
}
public isTypeScript(): boolean {
return (
this.extension === FILE_EXTENSIONS.TYPESCRIPT ||
this.extension === FILE_EXTENSIONS.TYPESCRIPT_JSX
)
}
public isJavaScript(): boolean {
return (
this.extension === FILE_EXTENSIONS.JAVASCRIPT ||
this.extension === FILE_EXTENSIONS.JAVASCRIPT_JSX
)
}
}

View File

@@ -0,0 +1,19 @@
/**
* Base class for Value Objects
* Value objects are immutable and compared by value, not identity
*/
export abstract class ValueObject<T> {
protected readonly props: T
constructor(props: T) {
this.props = Object.freeze(props)
}
public equals(vo?: ValueObject<T>): boolean {
if (!vo) {
return false
}
return JSON.stringify(this.props) === JSON.stringify(vo.props)
}
}

View File

@@ -0,0 +1,14 @@
export * from "./domain"
export * from "./application"
export * from "./infrastructure"
export * from "./shared"
export { analyzeProject } from "./api"
export type {
AnalyzeProjectRequest,
AnalyzeProjectResponse,
ArchitectureViolation,
HardcodeViolation,
CircularDependencyViolation,
ProjectMetrics,
} from "./api"

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)}`)
}
}
}

View File

@@ -0,0 +1,72 @@
export const APP_CONSTANTS = {
DEFAULT_TIMEOUT: 5000,
MAX_RETRIES: 3,
VERSION: "0.0.1",
} as const
export const ERROR_MESSAGES = {
VALIDATION_FAILED: "Validation failed",
NOT_FOUND: "Resource not found",
UNAUTHORIZED: "Unauthorized access",
INTERNAL_ERROR: "Internal server error",
FAILED_TO_ANALYZE: "Failed to analyze project",
FAILED_TO_SCAN_DIR: "Failed to scan directory",
FAILED_TO_READ_FILE: "Failed to read file",
ENTITY_NOT_FOUND: "Entity with id {id} not found",
} as const
/**
* Error codes
*/
export const ERROR_CODES = {
VALIDATION_ERROR: "VALIDATION_ERROR",
NOT_FOUND: "NOT_FOUND",
UNAUTHORIZED: "UNAUTHORIZED",
INTERNAL_ERROR: "INTERNAL_ERROR",
} as const
/**
* File extension constants
*/
export const FILE_EXTENSIONS = {
TYPESCRIPT: ".ts",
TYPESCRIPT_JSX: ".tsx",
JAVASCRIPT: ".js",
JAVASCRIPT_JSX: ".jsx",
} as const
/**
* TypeScript primitive type names
*/
export const TYPE_NAMES = {
STRING: "string",
NUMBER: "number",
BOOLEAN: "boolean",
OBJECT: "object",
} as const
/**
* Common regex patterns
*/
export const REGEX_PATTERNS = {
IMPORT_STATEMENT: /import\s+.*?\s+from\s+['"]([^'"]+)['"]/g,
EXPORT_STATEMENT: /export\s+(?:class|function|const|let|var)\s+(\w+)/g,
} as const
/**
* Placeholders for string templates
*/
export const PLACEHOLDERS = {
ID: "{id}",
} as const
/**
* Violation severity levels
*/
export const SEVERITY_LEVELS = {
ERROR: "error",
WARNING: "warning",
INFO: "info",
} as const
export * from "./rules"

View File

@@ -0,0 +1,126 @@
/**
* Rule names for code analysis
*/
export const RULES = {
CLEAN_ARCHITECTURE: "clean-architecture",
HARDCODED_VALUE: "hardcoded-value",
CIRCULAR_DEPENDENCY: "circular-dependency",
NAMING_CONVENTION: "naming-convention",
} as const
/**
* Hardcode types
*/
export const HARDCODE_TYPES = {
MAGIC_NUMBER: "magic-number",
MAGIC_STRING: "magic-string",
MAGIC_CONFIG: "magic-config",
} as const
/**
* Layer names
*/
export const LAYERS = {
DOMAIN: "domain",
APPLICATION: "application",
INFRASTRUCTURE: "infrastructure",
SHARED: "shared",
} as const
/**
* Naming convention violation types
*/
export const NAMING_VIOLATION_TYPES = {
WRONG_SUFFIX: "wrong-suffix",
WRONG_PREFIX: "wrong-prefix",
WRONG_CASE: "wrong-case",
FORBIDDEN_PATTERN: "forbidden-pattern",
WRONG_VERB_NOUN: "wrong-verb-noun",
} as const
/**
* Naming patterns for each layer
*/
export const NAMING_PATTERNS = {
DOMAIN: {
ENTITY: {
pattern: /^[A-Z][a-zA-Z0-9]*\.ts$/,
description: "PascalCase noun (User.ts, Order.ts)",
forbidden: ["Dto", "Request", "Response", "Controller"],
},
SERVICE: {
pattern: /^[A-Z][a-zA-Z0-9]*Service\.ts$/,
description: "*Service suffix (UserService.ts)",
},
VALUE_OBJECT: {
pattern: /^[A-Z][a-zA-Z0-9]*\.ts$/,
description: "PascalCase noun (Email.ts, Money.ts)",
},
REPOSITORY_INTERFACE: {
pattern: /^I[A-Z][a-zA-Z0-9]*Repository\.ts$/,
description: "I*Repository prefix (IUserRepository.ts)",
},
},
APPLICATION: {
USE_CASE: {
pattern: /^[A-Z][a-z]+[A-Z][a-zA-Z0-9]*\.ts$/,
description: "Verb in PascalCase (CreateUser.ts, UpdateProfile.ts)",
examples: ["CreateUser.ts", "UpdateProfile.ts", "DeleteOrder.ts"],
},
DTO: {
pattern: /^[A-Z][a-zA-Z0-9]*(Dto|Request|Response)\.ts$/,
description: "*Dto, *Request, *Response suffix",
examples: ["UserResponseDto.ts", "CreateUserRequest.ts"],
},
MAPPER: {
pattern: /^[A-Z][a-zA-Z0-9]*Mapper\.ts$/,
description: "*Mapper suffix (UserMapper.ts)",
},
},
INFRASTRUCTURE: {
CONTROLLER: {
pattern: /^[A-Z][a-zA-Z0-9]*Controller\.ts$/,
description: "*Controller suffix (UserController.ts)",
},
REPOSITORY_IMPL: {
pattern: /^[A-Z][a-zA-Z0-9]*Repository\.ts$/,
description: "*Repository suffix (PrismaUserRepository.ts, MongoUserRepository.ts)",
},
SERVICE: {
pattern: /^[A-Z][a-zA-Z0-9]*(Service|Adapter)\.ts$/,
description: "*Service or *Adapter suffix (EmailService.ts, S3StorageAdapter.ts)",
},
},
} as const
/**
* Common verbs for use cases
*/
export const USE_CASE_VERBS = [
"Analyze",
"Create",
"Update",
"Delete",
"Get",
"Find",
"List",
"Search",
"Validate",
"Calculate",
"Generate",
"Send",
"Fetch",
"Process",
"Execute",
"Handle",
"Register",
"Authenticate",
"Authorize",
"Import",
"Export",
"Place",
"Cancel",
"Approve",
"Reject",
"Confirm",
] as const

View File

@@ -0,0 +1,46 @@
import { ERROR_CODES } from "../constants"
/**
* Error codes (re-exported for backwards compatibility)
*/
const LEGACY_ERROR_CODES = ERROR_CODES
/**
* Base error class for custom application errors
*/
export abstract class BaseError extends Error {
public readonly timestamp: Date
public readonly code: string
constructor(message: string, code: string) {
super(message)
this.name = this.constructor.name
this.code = code
this.timestamp = new Date()
Error.captureStackTrace(this, this.constructor)
}
}
export class ValidationError extends BaseError {
constructor(message: string) {
super(message, LEGACY_ERROR_CODES.VALIDATION_ERROR)
}
}
export class NotFoundError extends BaseError {
constructor(message: string) {
super(message, LEGACY_ERROR_CODES.NOT_FOUND)
}
}
export class UnauthorizedError extends BaseError {
constructor(message: string) {
super(message, LEGACY_ERROR_CODES.UNAUTHORIZED)
}
}
export class InternalError extends BaseError {
constructor(message: string) {
super(message, LEGACY_ERROR_CODES.INTERNAL_ERROR)
}
}

View File

@@ -0,0 +1,4 @@
export * from "./types/Result"
export * from "./errors/BaseError"
export * from "./utils/Guards"
export * from "./constants"

View File

@@ -0,0 +1,29 @@
/**
* Result type for handling success/failure scenarios
*/
export type Result<T, E = Error> = Success<T> | Failure<E>
export class Success<T> {
public readonly isSuccess = true
public readonly isFailure = false
constructor(public readonly value: T) {}
public static create<T>(value: T): Success<T> {
return new Success(value)
}
}
export class Failure<E> {
public readonly isSuccess = false
public readonly isFailure = true
constructor(public readonly error: E) {}
public static create<E>(error: E): Failure<E> {
return new Failure(error)
}
}
export const ok = <T>(value: T): Result<T> => new Success(value)
export const fail = <E>(error: E): Result<never, E> => new Failure(error)

View File

@@ -0,0 +1,46 @@
import { TYPE_NAMES } from "../constants"
/**
* Type guard utilities for runtime type checking
*/
export class Guards {
public static isNullOrUndefined(value: unknown): value is null | undefined {
return value === null || value === undefined
}
public static isString(value: unknown): value is string {
return typeof value === TYPE_NAMES.STRING
}
public static isNumber(value: unknown): value is number {
return typeof value === TYPE_NAMES.NUMBER && !isNaN(value as number)
}
public static isBoolean(value: unknown): value is boolean {
return typeof value === TYPE_NAMES.BOOLEAN
}
public static isObject(value: unknown): value is object {
return typeof value === TYPE_NAMES.OBJECT && value !== null && !Array.isArray(value)
}
public static isArray<T>(value: unknown): value is T[] {
return Array.isArray(value)
}
public static isEmpty(value: string | unknown[] | object | null | undefined): boolean {
if (Guards.isNullOrUndefined(value)) {
return true
}
if (Guards.isString(value) || Guards.isArray(value)) {
return value.length === 0
}
if (Guards.isObject(value)) {
return Object.keys(value).length === 0
}
return false
}
}