mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 15:26: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:
@@ -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)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user