refactor: migrate hardcode detector from regex to AST-based analysis

- Replace regex-based matchers with tree-sitter AST traversal
- Add duplicate value tracking across files
- Implement boolean literal detection
- Add value type classification (email, url, ip, api_key, etc.)
- Improve context awareness with AST node analysis
- Reduce false positives with better constant detection

Breaking changes removed:
- BraceTracker.ts
- ExportConstantAnalyzer.ts
- MagicNumberMatcher.ts
- MagicStringMatcher.ts

New components added:
- AstTreeTraverser for AST walking
- DuplicateValueTracker for cross-file tracking
- AstContextChecker for node context analysis
- AstNumberAnalyzer, AstStringAnalyzer, AstBooleanAnalyzer
- ValuePatternMatcher for type detection

Test coverage: 87.97% statements, 96.75% functions
This commit is contained in:
imfozilbek
2025-11-26 17:38:30 +05:00
parent 656571860e
commit af094eb54a
24 changed files with 2641 additions and 648 deletions

View File

@@ -0,0 +1,55 @@
import { HardcodedValue } from "../value-objects/HardcodedValue"
export interface ValueLocation {
file: string
line: number
context: string
}
export interface DuplicateInfo {
value: string | number | boolean
locations: ValueLocation[]
count: number
}
/**
* Interface for tracking duplicate hardcoded values across files
*
* Helps identify values that are used in multiple places
* and should be extracted to a shared constant.
*/
export interface IDuplicateValueTracker {
/**
* Adds a hardcoded value to tracking
*/
track(violation: HardcodedValue, filePath: string): void
/**
* Gets all duplicate values (values used in 2+ places)
*/
getDuplicates(): DuplicateInfo[]
/**
* Gets duplicate locations for a specific value
*/
getDuplicateLocations(value: string | number | boolean, type: string): ValueLocation[] | null
/**
* Checks if a value is duplicated
*/
isDuplicate(value: string | number | boolean, type: string): boolean
/**
* Gets statistics about duplicates
*/
getStats(): {
totalValues: number
duplicateValues: number
duplicatePercentage: number
}
/**
* Clears all tracked values
*/
clear(): void
}

View File

@@ -1,15 +1,40 @@
import { ValueObject } from "./ValueObject"
import { HARDCODE_TYPES } from "../../shared/constants/rules"
import { DETECTION_PATTERNS, 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]
export type ValueType =
| "email"
| "url"
| "ip_address"
| "file_path"
| "date"
| "api_key"
| "uuid"
| "version"
| "color"
| "mac_address"
| "base64"
| "config"
| "generic"
export type ValueImportance = "critical" | "high" | "medium" | "low"
export interface DuplicateLocation {
file: string
line: number
}
interface HardcodedValueProps {
readonly value: string | number
readonly value: string | number | boolean
readonly type: HardcodeType
readonly valueType?: ValueType
readonly line: number
readonly column: number
readonly context: string
readonly duplicateLocations?: DuplicateLocation[]
readonly withinFileUsageCount?: number
}
/**
@@ -21,22 +46,28 @@ export class HardcodedValue extends ValueObject<HardcodedValueProps> {
}
public static create(
value: string | number,
value: string | number | boolean,
type: HardcodeType,
line: number,
column: number,
context: string,
valueType?: ValueType,
duplicateLocations?: DuplicateLocation[],
withinFileUsageCount?: number,
): HardcodedValue {
return new HardcodedValue({
value,
type,
valueType,
line,
column,
context,
duplicateLocations,
withinFileUsageCount,
})
}
public get value(): string | number {
public get value(): string | number | boolean {
return this.props.value
}
@@ -56,6 +87,28 @@ export class HardcodedValue extends ValueObject<HardcodedValueProps> {
return this.props.context
}
public get valueType(): ValueType | undefined {
return this.props.valueType
}
public get duplicateLocations(): DuplicateLocation[] | undefined {
return this.props.duplicateLocations
}
public get withinFileUsageCount(): number | undefined {
return this.props.withinFileUsageCount
}
public hasDuplicates(): boolean {
return (
this.props.duplicateLocations !== undefined && this.props.duplicateLocations.length > 0
)
}
public isAlmostConstant(): boolean {
return this.props.withinFileUsageCount !== undefined && this.props.withinFileUsageCount >= 2
}
public isMagicNumber(): boolean {
return this.props.type === HARDCODE_TYPES.MAGIC_NUMBER
}
@@ -106,6 +159,154 @@ export class HardcodedValue extends ValueObject<HardcodedValueProps> {
private suggestStringConstantName(): string {
const value = String(this.props.value)
const context = this.props.context.toLowerCase()
const valueType = this.props.valueType
if (valueType === "email") {
if (context.includes("admin")) {
return "ADMIN_EMAIL"
}
if (context.includes("support")) {
return "SUPPORT_EMAIL"
}
if (context.includes("noreply") || context.includes("no-reply")) {
return "NOREPLY_EMAIL"
}
return "DEFAULT_EMAIL"
}
if (valueType === "api_key") {
if (context.includes("secret")) {
return "API_SECRET_KEY"
}
if (context.includes("public")) {
return "API_PUBLIC_KEY"
}
return "API_KEY"
}
if (valueType === "url") {
if (context.includes("api")) {
return "API_BASE_URL"
}
if (context.includes("database") || context.includes("db")) {
return "DATABASE_URL"
}
if (context.includes("mongo")) {
return "MONGODB_CONNECTION_STRING"
}
if (context.includes("postgres") || context.includes("pg")) {
return "POSTGRES_URL"
}
return "BASE_URL"
}
if (valueType === "ip_address") {
if (context.includes("server")) {
return "SERVER_IP"
}
if (context.includes("database") || context.includes("db")) {
return "DATABASE_HOST"
}
if (context.includes("redis")) {
return "REDIS_HOST"
}
return "HOST_IP"
}
if (valueType === "file_path") {
if (context.includes("log")) {
return "LOG_FILE_PATH"
}
if (context.includes("config")) {
return "CONFIG_FILE_PATH"
}
if (context.includes("data")) {
return "DATA_DIR_PATH"
}
if (context.includes("temp")) {
return "TEMP_DIR_PATH"
}
return "FILE_PATH"
}
if (valueType === "date") {
if (context.includes("deadline")) {
return "DEADLINE"
}
if (context.includes("start")) {
return "START_DATE"
}
if (context.includes("end")) {
return "END_DATE"
}
if (context.includes("expir")) {
return "EXPIRATION_DATE"
}
return "DEFAULT_DATE"
}
if (valueType === "uuid") {
if (context.includes("id") || context.includes("identifier")) {
return "DEFAULT_ID"
}
if (context.includes("request")) {
return "REQUEST_ID"
}
if (context.includes("session")) {
return "SESSION_ID"
}
return "UUID_CONSTANT"
}
if (valueType === "version") {
if (context.includes("api")) {
return "API_VERSION"
}
if (context.includes("app")) {
return "APP_VERSION"
}
return "VERSION"
}
if (valueType === "color") {
if (context.includes("primary")) {
return "PRIMARY_COLOR"
}
if (context.includes("secondary")) {
return "SECONDARY_COLOR"
}
if (context.includes("background")) {
return "BACKGROUND_COLOR"
}
return "COLOR_CONSTANT"
}
if (valueType === "mac_address") {
return "MAC_ADDRESS"
}
if (valueType === "base64") {
if (context.includes("token")) {
return "ENCODED_TOKEN"
}
if (context.includes("key")) {
return "ENCODED_KEY"
}
return "BASE64_VALUE"
}
if (valueType === "config") {
if (context.includes("endpoint")) {
return "API_ENDPOINT"
}
if (context.includes("route")) {
return "ROUTE_PATH"
}
if (context.includes("connection")) {
return "CONNECTION_STRING"
}
return "CONFIG_VALUE"
}
if (value.includes(SUGGESTION_KEYWORDS.HTTP)) {
return CONSTANT_NAMES.API_BASE_URL
@@ -135,6 +336,23 @@ export class HardcodedValue extends ValueObject<HardcodedValueProps> {
}
const context = this.props.context.toLowerCase()
const valueType = this.props.valueType
if (valueType === "api_key" || valueType === "url" || valueType === "ip_address") {
return "src/config/environment.ts"
}
if (valueType === "email") {
return "src/config/contacts.ts"
}
if (valueType === "file_path") {
return "src/config/paths.ts"
}
if (valueType === "date") {
return "src/config/dates.ts"
}
if (
context.includes(SUGGESTION_KEYWORDS.ENTITY) ||
@@ -153,4 +371,122 @@ export class HardcodedValue extends ValueObject<HardcodedValueProps> {
return LOCATIONS.SHARED_CONSTANTS
}
public getDetailedSuggestion(currentLayer?: string): string {
const constantName = this.suggestConstantName()
const location = this.suggestLocation(currentLayer)
const valueTypeLabel = this.valueType ? ` (${this.valueType})` : ""
let suggestion = `Extract${valueTypeLabel} to constant ${constantName} in ${location}`
if (this.isAlmostConstant() && this.withinFileUsageCount) {
suggestion += `. This value appears ${String(this.withinFileUsageCount)} times in this file`
}
if (this.hasDuplicates() && this.duplicateLocations) {
const count = this.duplicateLocations.length
const fileList = this.duplicateLocations
.slice(0, 3)
.map((loc) => `${loc.file}:${String(loc.line)}`)
.join(", ")
const more = count > 3 ? ` and ${String(count - 3)} more` : ""
suggestion += `. Also duplicated in ${String(count)} other file(s): ${fileList}${more}`
}
return suggestion
}
/**
* Analyzes variable name and context to determine importance
*/
public getImportance(): ValueImportance {
const context = this.props.context.toLowerCase()
const valueType = this.props.valueType
if (valueType === "api_key") {
return "critical"
}
const criticalKeywords = [
...DETECTION_PATTERNS.SENSITIVE_KEYWORDS,
...DETECTION_PATTERNS.BUSINESS_KEYWORDS,
"key",
"age",
]
if (criticalKeywords.some((keyword) => context.includes(keyword))) {
return "critical"
}
const highKeywords = [...DETECTION_PATTERNS.TECHNICAL_KEYWORDS, "db", "api"]
if (highKeywords.some((keyword) => context.includes(keyword))) {
return "high"
}
if (valueType === "url" || valueType === "ip_address" || valueType === "email") {
return "high"
}
const mediumKeywords = DETECTION_PATTERNS.MEDIUM_KEYWORDS
if (mediumKeywords.some((keyword) => context.includes(keyword))) {
return "medium"
}
const lowKeywords = DETECTION_PATTERNS.UI_KEYWORDS
if (lowKeywords.some((keyword) => context.includes(keyword))) {
return "low"
}
return "medium"
}
/**
* Checks if this violation should be skipped based on layer strictness
*
* Different layers have different tolerance levels:
* - domain: strictest (no hardcoded values allowed)
* - application: strict (only low importance allowed)
* - infrastructure: moderate (low and some medium allowed)
* - cli: lenient (UI constants allowed)
*/
public shouldSkip(layer?: string): boolean {
if (!layer) {
return false
}
const importance = this.getImportance()
if (layer === "domain") {
return false
}
if (layer === "application") {
return false
}
if (layer === "infrastructure") {
return importance === "low" && this.isUIConstant()
}
if (layer === "cli") {
return importance === "low" && this.isUIConstant()
}
return false
}
/**
* Checks if this value is a UI-related constant
*/
private isUIConstant(): boolean {
const context = this.props.context.toLowerCase()
const uiKeywords = DETECTION_PATTERNS.UI_KEYWORDS
return uiKeywords.some((keyword) => context.includes(keyword))
}
}