Skip to content

Commit 987ed0c

Browse files
committed
feat(analyzer): add Phase 5 AI authorship heuristics (5 rules)
- over-commented (info, weight 4): comment density >= 40% in functions - hardcoded-config (warning, weight 10): hardcoded URLs, IPs, connection strings - inconsistent-error-handling (warning, weight 8): mixed try/catch and .catch() patterns - unnecessary-abstraction (warning, weight 7): single-method interfaces with no reuse - naming-inconsistency (warning, weight 6): mixed camelCase and snake_case in same scope Includes fix suggestions in printer.ts, updated README rules table, CHANGELOG Unreleased section, and version bump to 0.5.0
1 parent 7b6a5de commit 987ed0c

5 files changed

Lines changed: 340 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
99

1010
## [Unreleased]
1111

12+
### Added
13+
14+
- **Phase 5: AI authorship heuristics** — 5 new rules that detect patterns AI code generators produce
15+
- `over-commented` (info, weight 4): functions where comment density ≥ 40% — AI over-documents the obvious
16+
- `hardcoded-config` (warning, weight 10): hardcoded URLs, IPs, or connection strings instead of env vars
17+
- `inconsistent-error-handling` (warning, weight 8): mixed `try/catch` and `.catch()` patterns in the same file
18+
- `unnecessary-abstraction` (warning, weight 7): single-method interfaces or abstract classes never reused
19+
- `naming-inconsistency` (warning, weight 6): mixed camelCase and snake_case identifiers in the same scope
20+
1221
---
1322

1423
## [0.4.0] — 2026-02-23

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,11 @@ npx @eduardbar/drift scan ./src --fix
151151
| `magic-number` | info | Numeric literals used directly in logic — extract to named constants |
152152
| `layer-violation` | error | Layer imports a layer it's not allowed to (requires `drift.config.ts`) |
153153
| `cross-boundary-import` | warning | Module imports from another module outside allowed boundaries (requires `drift.config.ts`) |
154+
| `over-commented` | info | Functions where comments exceed 40% of lines — AI over-documents the obvious |
155+
| `hardcoded-config` | warning | Hardcoded URLs, IPs, or connection strings — AI skips environment variables |
156+
| `inconsistent-error-handling` | warning | Mixed `try/catch` and `.catch()` in the same file — AI combines styles randomly |
157+
| `unnecessary-abstraction` | warning | Single-method interfaces or abstract classes with no reuse — AI over-engineers |
158+
| `naming-inconsistency` | warning | Mixed camelCase and snake_case in the same scope — AI forgets project conventions |
154159

155160
---
156161

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@eduardbar/drift",
3-
"version": "0.4.0",
3+
"version": "0.5.0",
44
"description": "Detect silent technical debt left by AI-generated code",
55
"type": "module",
66
"main": "dist/index.js",

src/analyzer.ts

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ const RULE_WEIGHTS: Record<string, { severity: DriftIssue['severity']; weight: n
3939
// Phase 3b/c: layer and module boundary enforcement (require drift.config.ts)
4040
'layer-violation': { severity: 'error', weight: 16 },
4141
'cross-boundary-import': { severity: 'warning', weight: 10 },
42+
// Phase 5: AI authorship heuristics
43+
'over-commented': { severity: 'info', weight: 4 },
44+
'hardcoded-config': { severity: 'warning', weight: 10 },
45+
'inconsistent-error-handling': { severity: 'warning', weight: 8 },
46+
'unnecessary-abstraction': { severity: 'warning', weight: 7 },
47+
'naming-inconsistency': { severity: 'warning', weight: 6 },
4248
}
4349

4450
type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration
@@ -562,6 +568,299 @@ function detectCommentContradiction(file: SourceFile): DriftIssue[] {
562568
return issues
563569
}
564570

571+
// ---------------------------------------------------------------------------
572+
// Phase 5: AI authorship heuristics
573+
// ---------------------------------------------------------------------------
574+
575+
function detectOverCommented(file: SourceFile): DriftIssue[] {
576+
const issues: DriftIssue[] = []
577+
578+
for (const fn of file.getFunctions()) {
579+
const body = fn.getBody()
580+
if (!body) continue
581+
582+
const bodyText = body.getText()
583+
const lines = bodyText.split('\n')
584+
const totalLines = lines.length
585+
586+
if (totalLines < 6) continue
587+
588+
let commentLines = 0
589+
for (const line of lines) {
590+
const trimmed = line.trim()
591+
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*') || trimmed.startsWith('*/')) {
592+
commentLines++
593+
}
594+
}
595+
596+
const ratio = commentLines / totalLines
597+
if (ratio >= 0.4) {
598+
issues.push({
599+
rule: 'over-commented',
600+
severity: 'info',
601+
message: `Function has ${Math.round(ratio * 100)}% comment density (${commentLines}/${totalLines} lines). AI documents the obvious instead of the why.`,
602+
line: fn.getStartLineNumber(),
603+
column: fn.getStartLinePos(),
604+
snippet: fn.getName() ? `function ${fn.getName()}` : '(anonymous function)',
605+
})
606+
}
607+
}
608+
609+
for (const cls of file.getClasses()) {
610+
for (const method of cls.getMethods()) {
611+
const body = method.getBody()
612+
if (!body) continue
613+
614+
const bodyText = body.getText()
615+
const lines = bodyText.split('\n')
616+
const totalLines = lines.length
617+
618+
if (totalLines < 6) continue
619+
620+
let commentLines = 0
621+
for (const line of lines) {
622+
const trimmed = line.trim()
623+
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*') || trimmed.startsWith('*/')) {
624+
commentLines++
625+
}
626+
}
627+
628+
const ratio = commentLines / totalLines
629+
if (ratio >= 0.4) {
630+
issues.push({
631+
rule: 'over-commented',
632+
severity: 'info',
633+
message: `Method '${method.getName()}' has ${Math.round(ratio * 100)}% comment density (${commentLines}/${totalLines} lines). AI documents the obvious instead of the why.`,
634+
line: method.getStartLineNumber(),
635+
column: method.getStartLinePos(),
636+
snippet: `${cls.getName()}.${method.getName()}`,
637+
})
638+
}
639+
}
640+
}
641+
642+
return issues
643+
}
644+
645+
function detectHardcodedConfig(file: SourceFile): DriftIssue[] {
646+
const issues: DriftIssue[] = []
647+
648+
const CONFIG_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
649+
{ pattern: /^https?:\/\//i, label: 'HTTP/HTTPS URL' },
650+
{ pattern: /^wss?:\/\//i, label: 'WebSocket URL' },
651+
{ pattern: /^mongodb(\+srv)?:\/\//i, label: 'MongoDB connection string' },
652+
{ pattern: /^postgres(?:ql)?:\/\//i, label: 'PostgreSQL connection string' },
653+
{ pattern: /^mysql:\/\//i, label: 'MySQL connection string' },
654+
{ pattern: /^redis:\/\//i, label: 'Redis connection string' },
655+
{ pattern: /^amqps?:\/\//i, label: 'AMQP connection string' },
656+
{ pattern: /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, label: 'IP address' },
657+
{ pattern: /^:[0-9]{2,5}$/, label: 'Port number in string' },
658+
{ pattern: /^\/[a-z]/i, label: 'Absolute file path' },
659+
{ pattern: /localhost(:[0-9]+)?/i, label: 'localhost reference' },
660+
]
661+
662+
const filePath = file.getFilePath().replace(/\\/g, '/')
663+
if (filePath.includes('.test.') || filePath.includes('.spec.') || filePath.includes('__tests__')) {
664+
return issues
665+
}
666+
667+
for (const node of file.getDescendantsOfKind(SyntaxKind.StringLiteral)) {
668+
const value = node.getLiteralValue()
669+
if (!value || value.length < 4) continue
670+
671+
const parent = node.getParent()
672+
if (!parent) continue
673+
const parentKind = parent.getKindName()
674+
if (
675+
parentKind === 'ImportDeclaration' ||
676+
parentKind === 'ExportDeclaration' ||
677+
(parentKind === 'CallExpression' && parent.getText().startsWith('import('))
678+
) continue
679+
680+
for (const { pattern, label } of CONFIG_PATTERNS) {
681+
if (pattern.test(value)) {
682+
issues.push({
683+
rule: 'hardcoded-config',
684+
severity: 'warning',
685+
message: `Hardcoded ${label} detected. AI skips environment variables — extract to process.env or a config module.`,
686+
line: node.getStartLineNumber(),
687+
column: node.getStartLinePos(),
688+
snippet: value.length > 60 ? value.slice(0, 60) + '...' : value,
689+
})
690+
break
691+
}
692+
}
693+
}
694+
695+
return issues
696+
}
697+
698+
function detectInconsistentErrorHandling(file: SourceFile): DriftIssue[] {
699+
const issues: DriftIssue[] = []
700+
701+
let hasTryCatch = false
702+
let hasDotCatch = false
703+
let hasThenErrorHandler = false
704+
let firstLine = 0
705+
706+
// Detectar try/catch
707+
const tryCatches = file.getDescendantsOfKind(SyntaxKind.TryStatement)
708+
if (tryCatches.length > 0) {
709+
hasTryCatch = true
710+
firstLine = firstLine || tryCatches[0].getStartLineNumber()
711+
}
712+
713+
// Detectar .catch(handler) en call expressions
714+
for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
715+
const expr = call.getExpression()
716+
if (expr.getKindName() === 'PropertyAccessExpression') {
717+
const propAccess = expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression)
718+
const propName = propAccess.getName()
719+
if (propName === 'catch') {
720+
// Verificar que tiene al menos un argumento (handler real, no .catch() vacío)
721+
if (call.getArguments().length > 0) {
722+
hasDotCatch = true
723+
if (!firstLine) firstLine = call.getStartLineNumber()
724+
}
725+
}
726+
// Detectar .then(onFulfilled, onRejected) — segundo argumento = error handler
727+
if (propName === 'then' && call.getArguments().length >= 2) {
728+
hasThenErrorHandler = true
729+
if (!firstLine) firstLine = call.getStartLineNumber()
730+
}
731+
}
732+
}
733+
734+
const stylesUsed = [hasTryCatch, hasDotCatch, hasThenErrorHandler].filter(Boolean).length
735+
736+
if (stylesUsed >= 2) {
737+
const styles: string[] = []
738+
if (hasTryCatch) styles.push('try/catch')
739+
if (hasDotCatch) styles.push('.catch()')
740+
if (hasThenErrorHandler) styles.push('.then(_, handler)')
741+
742+
issues.push({
743+
rule: 'inconsistent-error-handling',
744+
severity: 'warning',
745+
message: `Mixed error handling styles: ${styles.join(', ')}. AI uses whatever pattern it saw last — pick one and stick to it.`,
746+
line: firstLine || 1,
747+
column: 1,
748+
snippet: styles.join(' + '),
749+
})
750+
}
751+
752+
return issues
753+
}
754+
755+
function detectUnnecessaryAbstraction(file: SourceFile): DriftIssue[] {
756+
const issues: DriftIssue[] = []
757+
const fileText = file.getFullText()
758+
759+
// Interfaces con un solo método
760+
for (const iface of file.getInterfaces()) {
761+
const methods = iface.getMethods()
762+
const properties = iface.getProperties()
763+
764+
// Solo reportar si tiene exactamente 1 método y 0 propiedades (abstracción pura de comportamiento)
765+
if (methods.length !== 1 || properties.length !== 0) continue
766+
767+
const ifaceName = iface.getName()
768+
769+
// Contar cuántas veces aparece el nombre en el archivo (excluyendo la declaración misma)
770+
const usageCount = (fileText.match(new RegExp(`\\b${ifaceName}\\b`, 'g')) ?? []).length
771+
// La declaración misma cuenta como 1 uso, implementaciones cuentan como 1 cada una
772+
// Si usageCount <= 2 (declaración + 1 uso), es candidata a innecesaria
773+
if (usageCount <= 2) {
774+
issues.push({
775+
rule: 'unnecessary-abstraction',
776+
severity: 'warning',
777+
message: `Interface '${ifaceName}' has 1 method and is used only once. AI creates abstractions preemptively — YAGNI.`,
778+
line: iface.getStartLineNumber(),
779+
column: iface.getStartLinePos(),
780+
snippet: `interface ${ifaceName} { ${methods[0].getName()}(...) }`,
781+
})
782+
}
783+
}
784+
785+
// Clases abstractas con un solo método abstracto y sin implementaciones en el archivo
786+
for (const cls of file.getClasses()) {
787+
if (!cls.isAbstract()) continue
788+
789+
const abstractMethods = cls.getMethods().filter(m => m.isAbstract())
790+
const concreteMethods = cls.getMethods().filter(m => !m.isAbstract())
791+
792+
if (abstractMethods.length !== 1 || concreteMethods.length !== 0) continue
793+
794+
const clsName = cls.getName() ?? ''
795+
const usageCount = (fileText.match(new RegExp(`\\b${clsName}\\b`, 'g')) ?? []).length
796+
797+
if (usageCount <= 2) {
798+
issues.push({
799+
rule: 'unnecessary-abstraction',
800+
severity: 'warning',
801+
message: `Abstract class '${clsName}' has 1 abstract method and is extended nowhere in this file. AI over-engineers single-use code.`,
802+
line: cls.getStartLineNumber(),
803+
column: cls.getStartLinePos(),
804+
snippet: `abstract class ${clsName}`,
805+
})
806+
}
807+
}
808+
809+
return issues
810+
}
811+
812+
function detectNamingInconsistency(file: SourceFile): DriftIssue[] {
813+
const issues: DriftIssue[] = []
814+
815+
const isCamelCase = (name: string) => /^[a-z][a-zA-Z0-9]*$/.test(name) && /[A-Z]/.test(name)
816+
const isSnakeCase = (name: string) => /^[a-z][a-z0-9]*(_[a-z0-9]+)+$/.test(name)
817+
818+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
819+
function checkFunction(fn: any): void {
820+
const vars = fn.getVariableDeclarations()
821+
if (vars.length < 3) return // muy pocas vars para ser significativo
822+
823+
let camelCount = 0
824+
let snakeCount = 0
825+
const snakeExamples: string[] = []
826+
const camelExamples: string[] = []
827+
828+
for (const v of vars) {
829+
const name = v.getName()
830+
if (isCamelCase(name)) {
831+
camelCount++
832+
if (camelExamples.length < 2) camelExamples.push(name)
833+
} else if (isSnakeCase(name)) {
834+
snakeCount++
835+
if (snakeExamples.length < 2) snakeExamples.push(name)
836+
}
837+
}
838+
839+
if (camelCount >= 1 && snakeCount >= 1) {
840+
issues.push({
841+
rule: 'naming-inconsistency',
842+
severity: 'warning',
843+
message: `Mixed naming conventions: camelCase (${camelExamples.join(', ')}) and snake_case (${snakeExamples.join(', ')}) in the same scope. AI mixes conventions from different training examples.`,
844+
line: fn.getStartLineNumber(),
845+
column: fn.getStartLinePos(),
846+
snippet: `camelCase: ${camelExamples[0]} / snake_case: ${snakeExamples[0]}`,
847+
})
848+
}
849+
}
850+
851+
for (const fn of file.getFunctions()) {
852+
checkFunction(fn)
853+
}
854+
855+
for (const cls of file.getClasses()) {
856+
for (const method of cls.getMethods()) {
857+
checkFunction(method)
858+
}
859+
}
860+
861+
return issues
862+
}
863+
565864
// ---------------------------------------------------------------------------
566865
// Score
567866
// ---------------------------------------------------------------------------
@@ -605,6 +904,12 @@ export function analyzeFile(file: SourceFile): FileReport {
605904
// Stubs now implemented
606905
...detectMagicNumbers(file),
607906
...detectCommentContradiction(file),
907+
// Phase 5: AI authorship heuristics
908+
...detectOverCommented(file),
909+
...detectHardcodedConfig(file),
910+
...detectInconsistentErrorHandling(file),
911+
...detectUnnecessaryAbstraction(file),
912+
...detectNamingInconsistency(file),
608913
]
609914

610915
return {

src/printer.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,26 @@ function formatFixSuggestion(issue: DriftIssue): string[] {
9393
'Or add the module to allowedExternalImports in drift.config.ts if this is intentional',
9494
'Consider using dependency injection or an event bus to decouple the modules',
9595
],
96+
'over-commented': [
97+
'Remove comments that restate what the code already expresses clearly',
98+
'Keep only comments that explain WHY, not WHAT — prefer self-documenting names',
99+
],
100+
'hardcoded-config': [
101+
'Move the value to an environment variable: process.env.YOUR_VAR',
102+
'Or extract it to a config file / constants module imported at the top',
103+
],
104+
'inconsistent-error-handling': [
105+
'Pick one style (async/await + try/catch is preferred) and apply it consistently',
106+
'Avoid mixing .then()/.catch() with await in the same file',
107+
],
108+
'unnecessary-abstraction': [
109+
'Inline the abstraction if it has only one implementation and is never reused',
110+
'Or document why the extension point exists (e.g., future plugin system)',
111+
],
112+
'naming-inconsistency': [
113+
'Pick one naming convention (camelCase for variables/functions, PascalCase for types)',
114+
'Rename snake_case identifiers to camelCase to match TypeScript conventions',
115+
],
96116
}
97117
return suggestions[issue.rule] ?? ['Review and fix manually']
98118
}

0 commit comments

Comments
 (0)