lib/App/GHGen/Detector.pm

Structural Coverage (Approximate)

TER1 (Statement): 100.00%
TER2 (Branch): 71.95%
TER3 (LCSAJ): 100.0% (3/3)
Approximate LCSAJ segments: 165

LCSAJ Legend

Covered — this LCSAJ path was executed during testing.

Not covered — this LCSAJ path was never executed. These are the paths to focus on.

Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:

        start → end → jump
        

Uncovered paths show [NOT COVERED] in the tooltip.

Mutant Testing Legend

Survived (tests missed this) Killed (tests detected this) No mutation
    1: package App::GHGen::Detector;
    2: 
    3: use v5.36;
    4: use strict;
    5: use warnings;
    6: 
    7: use Path::Tiny;
    8: 
    9: use Exporter 'import';
   10: our @EXPORT_OK = qw(
   11: 	detect_project_type
   12: 	get_project_indicators
   13: );
   14: 
   15: our $VERSION = '0.10';
   16: 
   17: =head1 NAME
   18: 
   19: App::GHGen::Detector - Detect project type from repository contents
   20: 
   21: =head1 SYNOPSIS
   22: 
   23: 	use App::GHGen::Detector qw(detect_project_type);
   24: 
   25: 	my $type = detect_project_type();
   26: 	# Returns: 'perl', 'node', 'python', etc.
   27: 
   28: =head1 FUNCTIONS
   29: 
   30: =head2 detect_project_type()
   31: 
   32: Detect the project type by examining indicator files in the current directory.
   33: 
   34: =head3 Purpose
   35: 
   36: Score the current working directory against ten known project types
   37: (perl, node, python, rust, go, ruby, php, java, cpp, docker) and return
   38: the highest-scoring type.
   39: 
   40: =head3 Arguments
   41: 
   42: None.
   43: 
   44: =head3 Returns
   45: 
   46: In scalar context: the type string (e.g. C<'perl'>) of the best match, or
   47: C<undef> when no type scores above zero.
   48: 
   49: In list context: a list of C<{ type => Str, score => Int, indicators => [] }>
   50: hash references sorted by descending score.
   51: 
   52: =head3 Side Effects
   53: 
   54: Reads the filesystem; results depend on the current working directory.
   55: 
   56: =head3 Usage Example
   57: 
   58:     use App::GHGen::Detector qw(detect_project_type);
   59:     my $type = detect_project_type();
   60:     say $type // 'unknown';
   61: 
   62: =head3 API SPECIFICATION
   63: 
   64: =head4 Input
   65: 
   66:     # No parameters.
   67: 
   68: =head4 Output (scalar context)
   69: 
   70:     { type => 'scalar' }   # may be undef
   71: 
   72: =head4 Output (list context)
   73: 
   74:     { type => 'array', element => { type => 'hashref' } }
   75: 
   76: =head3 FORMAL SPECIFICATION
   77: 
   78:     detect_project_type : → ℤ* ∪ { ⊥ } | seq Detection
   79: 
   80:     Detection ≔ { type: ProjectType, score: ℕ, indicators: seq ℤ* }
   81:     detections ≔ [ { type: t, score: score(t) } ∣ t ∈ Types, score(t) > 0 ]
   82:     ranked     ≔ sort desc-score detections
   83: 
   84:     scalar context: ranked = ∅ → ⊥  |  ranked ≠ ∅ → ranked[0].type
   85:     list context:   ranked
   86: 
   87: =cut
   88: 
   89: sub detect_project_type() {
90 → 93 → 100   90: 	my @detections;
   91: 
   92: 	# Check for each project type
   93: 	for my $type (qw(perl node python rust go ruby php java cpp docker)) {
   94: 		my $detector = "_detect_$type";
   95: 		my $score = __PACKAGE__->can($detector)->();
   96: 		push @detections, { type => $type, score => $score, indicators => [] } if $score > 0;

Mutants (Total: 3, Killed: 3, Survived: 0)

97: } 98: 99: # Sort by score (highest first) 100: @detections = sort { $b->{score} <=> $a->{score} } @detections; 101: 102: # `return;` returns () in list context and undef in scalar context — correct for both. 103: # `return undef` would return (undef) in list context, i.e. a one-element list. 104: return unless @detections; 105: return wantarray ? @detections : $detections[0]->{type};

Mutants (Total: 2, Killed: 2, Survived: 0)

106: } 107: 108: =head2 get_project_indicators($type) 109: 110: Return the indicator file patterns associated with a given project type. 111: 112: =head3 Purpose 113: 114: Provide a human-readable or programmatic list of the files and patterns that 115: the detector uses to score each project type. 116: 117: =head3 Arguments 118: 119: =over 4 120: 121: =item C<$type> (Str, optional) 122: 123: A project type string such as C<'perl'>, C<'node'>, C<'python'>, etc. 124: When omitted or C<undef>, the full indicator table is returned. 125: 126: =back 127: 128: =head3 Returns 129: 130: When C<$type> is given and known: an array reference of strings (file names / 131: glob patterns). 132: 133: When C<$type> is C<undef> or omitted: a hash reference mapping every known 134: type to its indicator list. 135: 136: When C<$type> is given but unknown: C<undef>. 137: 138: =head3 Side Effects 139: 140: None. Pure lookup function. 141: 142: =head3 Usage Example 143: 144: my $indicators = get_project_indicators('perl'); 145: say for @$indicators; 146: 147: my $all = get_project_indicators(); 148: for my $type (sort keys %$all) { 149: say "$type: " . join(', ', @{$all->{$type}}); 150: } 151: 152: =head3 API SPECIFICATION 153: 154: =head4 Input 155: 156: { type => { type => 'scalar', optional => 1 } } 157: 158: =head4 Output 159: 160: known type → { type => 'arrayref' } 161: all types → { type => 'hashref' } 162: unknown type → undef 163: 164: =head3 FORMAL SPECIFICATION 165: 166: get_project_indicators : ℤ* ∪ { ⊥ } → seq ℤ* ∪ (ℤ* → seq ℤ*) ∪ { ⊥ } 167: 168: t given ∧ t ∈ KnownTypes → indicators[t] (ArrayRef) 169: t given ∧ t ∉ KnownTypes → ⊥ (undef) 170: t absent → indicators (HashRef of all types) 171: 172: =cut 173: 174: sub get_project_indicators($type = undef) { 175: my %indicators = ( 176: perl => [ 177: 'cpanfile', 'dist.ini', 'Makefile.PL', 'Build.PL', 178: 'lib/*.pm', 't/*.t', 'META.json', 'META.yml' 179: ], 180: node => [ 181: 'package.json', 'package-lock.json', 'yarn.lock', 182: 'node_modules/', 'tsconfig.json', '.npmrc' 183: ], 184: python => [ 185: 'requirements.txt', 'setup.py', 'pyproject.toml', 186: 'Pipfile', 'poetry.lock', 'setup.cfg', 'tox.ini' 187: ], 188: rust => [ 189: 'Cargo.toml', 'Cargo.lock', 'src/main.rs', 190: 'src/lib.rs', 'rust-toolchain.toml' 191: ], 192: go => [ 193: 'go.mod', 'go.sum', 'main.go', '*.go' 194: ], 195: ruby => [ 196: 'Gemfile', 'Gemfile.lock', 'Rakefile', 197: '.ruby-version', 'config.ru' 198: ], 199: php => [ 200: 'composer.json', 'composer.lock', 'phpunit.xml', 201: 'phpunit.xml.dist', 'src/', 'tests/' 202: ], 203: java => [ 204: 'pom.xml', 'build.gradle', 'build.gradle.kts', 205: 'gradlew', 'mvnw', 'src/main/java/' 206: ], 207: cpp => [ 208: 'CMakeLists.txt', 'Makefile', 'configure.ac', 209: '*.cpp', '*.hpp', '*.cc', '*.h' 210: ], 211: c => [ 212: 'CMakeLists.txt', 'Makefile', 'configure.ac', 213: '*.c', '*.h', 214: ], 215: docker => [ 216: 'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml', 217: '.dockerignore' 218: ], 219: ); 220: 221: return $type ? $indicators{$type} : \%indicators;

Mutants (Total: 2, Killed: 2, Survived: 0)

222: } 223: 224: # Detection functions - return a score (0 = not detected, higher = more confident) 225: 226: sub _detect_perl() { 227 → 244 → 250 227: my $score = 0; 228: 229: # Strong indicators 230: $score += 10 if path('cpanfile')->exists; 231: $score += 10 if path('dist.ini')->exists; 232: $score += 8 if path('Makefile.PL')->exists; 233: $score += 8 if path('Build.PL')->exists; 234: 235: # Medium indicators 236: $score += 5 if path('META.json')->exists; 237: $score += 5 if path('META.yml')->exists; 238: 239: # Weak indicators 240: $score += 3 if path('lib')->exists && path('lib')->is_dir; 241: $score += 2 if path('t')->exists && path('t')->is_dir; 242: 243: # Check for .pm files in lib/ 244: if (path('lib')->exists) {

Mutants (Total: 1, Killed: 1, Survived: 0)

245: my @pm_files = path('lib')->children(qr/\.pm$/); 246: $score += 2 if @pm_files > 0;

Mutants (Total: 3, Killed: 0, Survived: 3)
247: } 248: 249: # Check for .t files in t/ 250 → 250 → 255 250: if (path('t')->exists) {

Mutants (Total: 1, Killed: 1, Survived: 0)

251: my @t_files = path('t')->children(qr/\.t$/); 252: $score += 1 if @t_files > 0;

Mutants (Total: 3, Killed: 0, Survived: 3)
253: } 254: 255: return $score;

Mutants (Total: 2, Killed: 2, Survived: 0)

256: } 257: 258: sub _detect_node() { 259: my $score = 0; 260: 261: # Strong indicators 262: $score += 15 if path('package.json')->exists; 263: $score += 8 if path('package-lock.json')->exists; 264: $score += 8 if path('yarn.lock')->exists; 265: $score += 7 if path('pnpm-lock.yaml')->exists; 266: 267: # Medium indicators 268: $score += 5 if path('node_modules')->exists && path('node_modules')->is_dir; 269: $score += 4 if path('tsconfig.json')->exists; 270: $score += 3 if path('.npmrc')->exists; 271: 272: # Weak indicators 273: $score += 2 if path('src')->exists && path('src')->is_dir; 274: 275: return $score;

Mutants (Total: 2, Killed: 2, Survived: 0)

276: } 277: 278: sub _detect_python() { 279: my $score = 0; 280: 281: # Strong indicators 282: $score += 12 if path('requirements.txt')->exists; 283: $score += 12 if path('setup.py')->exists; 284: $score += 12 if path('pyproject.toml')->exists; 285: $score += 10 if path('Pipfile')->exists; 286: $score += 8 if path('poetry.lock')->exists; 287: 288: # Medium indicators 289: $score += 5 if path('setup.cfg')->exists; 290: $score += 4 if path('tox.ini')->exists; 291: $score += 3 if path('.python-version')->exists; 292: 293: # Weak indicators 294: $score += 2 if path('venv')->exists || path('.venv')->exists; 295: 296: # Check for .py files 297: my @py_files = path('.')->children(qr/\.py$/); 298: $score += 2 if @py_files > 0;

Mutants (Total: 3, Killed: 0, Survived: 3)
299: 300: return $score;

Mutants (Total: 2, Killed: 2, Survived: 0)

301: } 302: 303: sub _detect_rust() { 304: my $score = 0; 305: 306: # Strong indicators 307: $score += 15 if path('Cargo.toml')->exists; 308: $score += 10 if path('Cargo.lock')->exists; 309: 310: # Medium indicators 311: $score += 5 if path('src/main.rs')->exists; 312: $score += 5 if path('src/lib.rs')->exists; 313: $score += 3 if path('rust-toolchain.toml')->exists || path('rust-toolchain')->exists; 314: 315: # Weak indicators 316: $score += 2 if path('target')->exists && path('target')->is_dir; 317: 318: return $score;

Mutants (Total: 2, Killed: 2, Survived: 0)

319: } 320: 321: sub _detect_go() { 322: my $score = 0; 323: 324: # Strong indicators 325: $score += 15 if path('go.mod')->exists; 326: $score += 10 if path('go.sum')->exists; 327: 328: # Medium indicators 329: $score += 5 if path('main.go')->exists; 330: 331: # Check for .go files 332: my @go_files = path('.')->children(qr/\.go$/); 333: $score += 3 if @go_files > 0;

Mutants (Total: 3, Killed: 0, Survived: 3)
334: $score += 1 if @go_files > 3;

Mutants (Total: 3, Killed: 3, Survived: 0)

335: 336: return $score;

Mutants (Total: 2, Killed: 2, Survived: 0)

337: } 338: 339: sub _detect_ruby() { 340: my $score = 0; 341: 342: # Strong indicators 343: $score += 15 if path('Gemfile')->exists; 344: $score += 10 if path('Gemfile.lock')->exists; 345: 346: # Medium indicators 347: $score += 5 if path('Rakefile')->exists; 348: $score += 4 if path('.ruby-version')->exists; 349: $score += 3 if path('config.ru')->exists; 350: 351: # Check for .rb files 352: my @rb_files = path('.')->children(qr/\.rb$/); 353: $score += 2 if @rb_files > 0;

Mutants (Total: 3, Killed: 0, Survived: 3)
354: 355: return $score;

Mutants (Total: 2, Killed: 2, Survived: 0)

356: } 357: 358: sub _detect_docker() { 359: my $score = 0; 360: 361: # Strong indicators 362: $score += 12 if path('Dockerfile')->exists; 363: $score += 8 if path('docker-compose.yml')->exists; 364: $score += 8 if path('docker-compose.yaml')->exists; 365: 366: # Medium indicators 367: $score += 3 if path('.dockerignore')->exists; 368: 369: return $score;

Mutants (Total: 2, Killed: 2, Survived: 0)

370: } 371: 372: sub _detect_php() { 373: my $score = 0; 374: 375: # Strong indicators 376: $score += 15 if path('composer.json')->exists; 377: $score += 10 if path('composer.lock')->exists; 378: 379: # Medium indicators 380: $score += 5 if path('phpunit.xml')->exists; 381: $score += 5 if path('phpunit.xml.dist')->exists; 382: $score += 3 if path('.php-version')->exists; 383: 384: # Weak indicators 385: $score += 2 if path('src')->exists && path('src')->is_dir; 386: $score += 2 if path('tests')->exists && path('tests')->is_dir; 387: 388: # Check for .php files 389: my @php_files = path('.')->children(qr/\.php$/); 390: $score += 2 if @php_files > 0;

Mutants (Total: 3, Killed: 0, Survived: 3)
391: 392: return $score;

Mutants (Total: 2, Killed: 2, Survived: 0)

393: } 394: 395: sub _detect_java() { 396: my $score = 0; 397: 398: # Strong indicators 399: $score += 15 if path('pom.xml')->exists; 400: $score += 15 if path('build.gradle')->exists; 401: $score += 15 if path('build.gradle.kts')->exists; 402: 403: # Medium indicators 404: $score += 5 if path('gradlew')->exists; 405: $score += 5 if path('mvnw')->exists; 406: $score += 4 if path('settings.gradle')->exists; 407: $score += 4 if path('settings.gradle.kts')->exists; 408: 409: # Weak indicators 410: $score += 3 if path('src/main/java')->exists; 411: $score += 2 if path('src/test/java')->exists; 412: 413: # Check for .java files 414: my @java_files = path('.')->children(qr/\.java$/); 415: $score += 2 if @java_files > 0;

Mutants (Total: 3, Killed: 0, Survived: 3)
416: 417: return $score;

Mutants (Total: 2, Killed: 2, Survived: 0)

418: } 419: 420: sub _detect_cpp() { 421: my $score = 0; 422: 423: # Strong indicators 424: $score += 12 if path('CMakeLists.txt')->exists; 425: $score += 8 if path('Makefile')->exists; 426: $score += 6 if path('configure.ac')->exists; 427: $score += 6 if path('configure')->exists; 428: 429: # Medium indicators 430: $score += 4 if path('meson.build')->exists; 431: $score += 3 if path('.clang-format')->exists; 432: 433: # Check for C++ files 434: my @cpp_files = path('.')->children(qr/\.(?:cpp|cc|cxx|hpp|hxx|h)$/); 435: $score += 3 if @cpp_files > 0;

Mutants (Total: 3, Killed: 0, Survived: 3)
436: $score += 2 if @cpp_files > 5;

Mutants (Total: 3, Killed: 3, Survived: 0)

437: 438: # Check for include directory 439: $score += 2 if path('include')->exists && path('include')->is_dir; 440: 441: return $score;

Mutants (Total: 2, Killed: 2, Survived: 0)

442: } 443: 444: =head1 AUTHOR 445: 446: Nigel Horne E<lt>njh@nigelhorne.comE<gt> 447: 448: L<https://github.com/nigelhorne> 449: 450: =head1 LICENSE 451: 452: This is free software; you can redistribute it and/or modify it under 453: the same terms as the Perl 5 programming language system itself. 454: 455: =cut 456: 457: 1;