@@ -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
4450type 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 : / ^ h t t p s ? : \/ \/ / i, label : 'HTTP/HTTPS URL' } ,
650+ { pattern : / ^ w s s ? : \/ \/ / i, label : 'WebSocket URL' } ,
651+ { pattern : / ^ m o n g o d b ( \+ s r v ) ? : \/ \/ / i, label : 'MongoDB connection string' } ,
652+ { pattern : / ^ p o s t g r e s (?: q l ) ? : \/ \/ / i, label : 'PostgreSQL connection string' } ,
653+ { pattern : / ^ m y s q l : \/ \/ / i, label : 'MySQL connection string' } ,
654+ { pattern : / ^ r e d i s : \/ \/ / i, label : 'Redis connection string' } ,
655+ { pattern : / ^ a m q p s ? : \/ \/ / 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 : / l o c a l h o s t ( : [ 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 - z A - Z 0 - 9 ] * $ / . test ( name ) && / [ A - Z ] / . test ( name )
816+ const isSnakeCase = ( name : string ) => / ^ [ a - z ] [ a - z 0 - 9 ] * ( _ [ a - z 0 - 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 {
0 commit comments