mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 07:16:53 +05:00
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:
90
packages/guardian/src/api.ts
Normal file
90
packages/guardian/src/api.ts
Normal 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"
|
||||
31
packages/guardian/src/application/dtos/ResponseDto.ts
Normal file
31
packages/guardian/src/application/dtos/ResponseDto.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
4
packages/guardian/src/application/index.ts
Normal file
4
packages/guardian/src/application/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./use-cases/BaseUseCase"
|
||||
export * from "./use-cases/AnalyzeProject"
|
||||
export * from "./dtos/ResponseDto"
|
||||
export * from "./mappers/BaseMapper"
|
||||
20
packages/guardian/src/application/mappers/BaseMapper.ts
Normal file
20
packages/guardian/src/application/mappers/BaseMapper.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
344
packages/guardian/src/application/use-cases/AnalyzeProject.ts
Normal file
344
packages/guardian/src/application/use-cases/AnalyzeProject.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
13
packages/guardian/src/application/use-cases/BaseUseCase.ts
Normal file
13
packages/guardian/src/application/use-cases/BaseUseCase.ts
Normal 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>
|
||||
}
|
||||
63
packages/guardian/src/cli/constants.ts
Normal file
63
packages/guardian/src/cli/constants.ts
Normal 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
|
||||
159
packages/guardian/src/cli/index.ts
Normal file
159
packages/guardian/src/cli/index.ts
Normal 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()
|
||||
53
packages/guardian/src/domain/constants/Suggestions.ts
Normal file
53
packages/guardian/src/domain/constants/Suggestions.ts
Normal 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
|
||||
44
packages/guardian/src/domain/entities/BaseEntity.ts
Normal file
44
packages/guardian/src/domain/entities/BaseEntity.ts
Normal 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
|
||||
}
|
||||
}
|
||||
109
packages/guardian/src/domain/entities/DependencyGraph.ts
Normal file
109
packages/guardian/src/domain/entities/DependencyGraph.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
86
packages/guardian/src/domain/entities/SourceFile.ts
Normal file
86
packages/guardian/src/domain/entities/SourceFile.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
25
packages/guardian/src/domain/events/DomainEvent.ts
Normal file
25
packages/guardian/src/domain/events/DomainEvent.ts
Normal 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
|
||||
}
|
||||
}
|
||||
13
packages/guardian/src/domain/index.ts
Normal file
13
packages/guardian/src/domain/index.ts
Normal 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"
|
||||
14
packages/guardian/src/domain/repositories/IBaseRepository.ts
Normal file
14
packages/guardian/src/domain/repositories/IBaseRepository.ts
Normal 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>
|
||||
}
|
||||
10
packages/guardian/src/domain/services/ICodeParser.ts
Normal file
10
packages/guardian/src/domain/services/ICodeParser.ts
Normal 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[]
|
||||
}
|
||||
15
packages/guardian/src/domain/services/IFileScanner.ts
Normal file
15
packages/guardian/src/domain/services/IFileScanner.ts
Normal 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>
|
||||
}
|
||||
10
packages/guardian/src/domain/services/IHardcodeDetector.ts
Normal file
10
packages/guardian/src/domain/services/IHardcodeDetector.ts
Normal 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[]
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
156
packages/guardian/src/domain/value-objects/HardcodedValue.ts
Normal file
156
packages/guardian/src/domain/value-objects/HardcodedValue.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
56
packages/guardian/src/domain/value-objects/ProjectPath.ts
Normal file
56
packages/guardian/src/domain/value-objects/ProjectPath.ts
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
19
packages/guardian/src/domain/value-objects/ValueObject.ts
Normal file
19
packages/guardian/src/domain/value-objects/ValueObject.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
14
packages/guardian/src/index.ts
Normal file
14
packages/guardian/src/index.ts
Normal 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"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
91
packages/guardian/src/infrastructure/constants/defaults.ts
Normal file
91
packages/guardian/src/infrastructure/constants/defaults.ts
Normal 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
|
||||
@@ -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
|
||||
3
packages/guardian/src/infrastructure/index.ts
Normal file
3
packages/guardian/src/infrastructure/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./parsers/CodeParser"
|
||||
export * from "./scanners/FileScanner"
|
||||
export * from "./analyzers/HardcodeDetector"
|
||||
58
packages/guardian/src/infrastructure/parsers/CodeParser.ts
Normal file
58
packages/guardian/src/infrastructure/parsers/CodeParser.ts
Normal 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
|
||||
}
|
||||
}
|
||||
69
packages/guardian/src/infrastructure/scanners/FileScanner.ts
Normal file
69
packages/guardian/src/infrastructure/scanners/FileScanner.ts
Normal 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)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
72
packages/guardian/src/shared/constants/index.ts
Normal file
72
packages/guardian/src/shared/constants/index.ts
Normal 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"
|
||||
126
packages/guardian/src/shared/constants/rules.ts
Normal file
126
packages/guardian/src/shared/constants/rules.ts
Normal 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
|
||||
46
packages/guardian/src/shared/errors/BaseError.ts
Normal file
46
packages/guardian/src/shared/errors/BaseError.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
4
packages/guardian/src/shared/index.ts
Normal file
4
packages/guardian/src/shared/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./types/Result"
|
||||
export * from "./errors/BaseError"
|
||||
export * from "./utils/Guards"
|
||||
export * from "./constants"
|
||||
29
packages/guardian/src/shared/types/Result.ts
Normal file
29
packages/guardian/src/shared/types/Result.ts
Normal 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)
|
||||
46
packages/guardian/src/shared/utils/Guards.ts
Normal file
46
packages/guardian/src/shared/utils/Guards.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user