diff --git a/index.html b/index.html index 01f4bfc..2ea29b9 100644 --- a/index.html +++ b/index.html @@ -742,6 +742,23 @@ }; } +function makeFetchFailedAnalysisFile(file){ + return{ + path:file.path, + name:file.name, + folder:file.folder, + content:'', + functions:[], + lines:0, + layer:Parser.detectLayer(file.path), + churn:0, + isCode:false, + size:file.size||0, + analysisSkipped:'fetch-failed', + parserProvenance:'skipped:fetch-failed' + }; +} + function getArchiveRootPrefix(paths){ var splitPaths=(paths||[]).map(function(path){return normalizeExcludePath(path).split('/').filter(Boolean);}).filter(function(parts){return parts.length>0;}); if(!splitPaths.length)return''; @@ -1051,6 +1068,20 @@ return Parser.getEmbeddedCodeBlocks(content,filename,{includeHandlers:true}).length>0; }, isMarkdown:function(n){return ['.md','.markdown'].some(function(e){return n.toLowerCase().endsWith(e);});}, + // Multi-language test-file conventions: JS (.test./.spec./__tests__), Ruby + // (spec/**/*_spec.rb, test/**/*_test.rb), Python (test_*.py, *_test.py), + // Go (*_test.go), JVM/C#/PHP (*Test.java etc.), Elixir (*_test.exs). + isTestFile:function(path){ + var p=String(path||'').replace(/\\/g,'/'); + var lower=p.toLowerCase(); + if(/(^|\/)(tests?|spec|specs|__tests__)\//.test(lower))return true; + if(/\.(test|spec)\.[a-z]+$/.test(lower))return true; + if(/_(test|spec)\.(rb|go|py|exs|ex|cr|php|rs)$/.test(lower))return true; + if(/(^|\/)test_[^\/]*\.py$/.test(lower))return true; + if(/(^|\/)conftest\.py$/.test(lower))return true; + if(/(Test|Tests|Spec)\.(java|kt|kts|scala|cs|groovy|swift)$/.test(p))return true; + return false; + }, // Mirror of tests/md-extractors.mjs::extractMarkdownLinks. Keep in sync. extractMarkdownLinks:function(content){ if(!content)return[]; @@ -1475,13 +1506,15 @@ if(!blocks.length)return{score:0,level:'low'}; content=blocks.map(function(block){return block.content;}).join('\n'); } - // Approximate cyclomatic complexity - supports JS, Python, and other languages + // Approximate cyclomatic complexity - supports JS, Python, Ruby, and other languages var complexity=1; // JS/C-style patterns var patterns=[/\bif\s*\(/g,/\belse\s+if\s*\(/g,/\bwhile\s*\(/g,/\bfor\s*\(/g,/\bcase\s+/g,/\bcatch\s*\(/g,/\?\s*[^:]+\s*:/g,/&&/g,/\|\|/g]; // Python-specific patterns var pyPatterns=[/\bif\s+[^(]/g,/\belif\s+/g,/\bwhile\s+[^(]/g,/\bfor\s+\w+\s+in\s+/g,/\bexcept\s*/g,/\bwith\s+/g,/\band\b/g,/\bor\b/g,/\bif\s+.+\s+else\s+/g,/\bfor\s+.+\s+in\s+[^\n]*\]/g]; - patterns.concat(pyPatterns).forEach(function(p){var m=content.match(p);if(m)complexity+=m.length;}); + // Ruby-specific branch keywords (if/while are covered by the Python patterns) + var rbPatterns=[/\belsif\s+/g,/\bunless\s+/g,/\bwhen\s+/g,/\brescue\b/g,/\buntil\s+/g]; + patterns.concat(pyPatterns,rbPatterns).forEach(function(p){var m=content.match(p);if(m)complexity+=m.length;}); // Deduplicate: if both `if (` and `if ` match the same lines, the count is inflated // but for a quick approximation this is acceptable var level='low'; @@ -1532,7 +1565,7 @@ suggestions.push({priority:'critical',icon:'shield',title:'Fix Security Issues',desc:highSec.length+' high-severity security issues found.',action:'Address hardcoded secrets, injection risks immediately',impact:'Prevents potential security breaches'}); } // Test coverage hint - var testFiles=data.files.filter(function(f){return f.name.includes('.test.')||f.name.includes('.spec.')||f.path.includes('__tests__');}); + var testFiles=data.files.filter(function(f){return Parser.isTestFile(f.path);}); var testRatio=data.files.length>0?(testFiles.length/data.files.length*100):0; if(testRatio<10&&data.files.length>10){ suggestions.push({priority:'medium',icon:'beaker',title:'Add Test Coverage',desc:'Only '+testFiles.length+' test files found ('+Math.round(testRatio)+'%). Consider adding more tests.',action:'Focus on testing critical paths and high-complexity files',impact:'Prevents regressions and improves confidence'}); @@ -2299,8 +2332,9 @@ if((m=line.match(/(?:suspend\s+)?fun\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*[<(]/))) addFn({name:m[1],file:filename,line:lineNum,code:extractCode(lineNum),isTopLevel:true,type:'function'}); - // Ruby: def name - if((m=line.match(/^\s*def\s+([a-zA-Z_][a-zA-Z0-9_?!]*)/))) + // Ruby: def name / def self.name (singleton methods keep the real + // method name — reporting them as "self" makes findings unreadable) + if((m=line.match(/^\s*def\s+(?:self\s*\.\s*)?([a-zA-Z_][a-zA-Z0-9_]*[?!=]?)/))) addFn({name:m[1],file:filename,line:lineNum,code:extractCode(lineNum),isTopLevel:true,type:'function'}); // Rust: fn name or pub fn name @@ -2680,6 +2714,7 @@ var fromExt=(fromPath.split('.').pop()||'').toLowerCase(); var isPython=['py','pyw','pyi'].indexOf(fromExt)>=0; var isPascal=['pas','pp','dpr','dpk','lpr','inc'].indexOf(fromExt)>=0; + var isRuby=['rb','rake'].indexOf(fromExt)>=0; var candidates=[]; function normalizePath(path){ var out=[]; @@ -2712,6 +2747,11 @@ var unitPath=importPath.replace(/\./g,'/'); addCandidate((fromDir?fromDir+'/':'')+unitPath); addCandidate(unitPath); + }else if(isRuby){ + // Plain `require "ruby_llm/contract"` resolves from the gem load + // path — for repos that is conventionally lib/ (or the repo root). + addCandidate('lib/'+importPath); + addCandidate(importPath); }else{ return null; } @@ -2719,7 +2759,7 @@ var pathMap=filesOrIndex&&filesOrIndex.pathMap ?filesOrIndex.pathMap :Parser.buildCallGraphPathIndex(filesOrIndex).pathMap; - var exts=['','.js','.jsx','.ts','.tsx','.mjs','.cjs','.vue','.svelte','.py','.pyw','.pyi','.vba','.bas','.cls','.pas','.pp','.inc','/index.js','/index.jsx','/index.ts','/index.tsx','/__init__.py']; + var exts=['','.js','.jsx','.ts','.tsx','.mjs','.cjs','.vue','.svelte','.py','.pyw','.pyi','.rb','.vba','.bas','.cls','.pas','.pp','.inc','/index.js','/index.jsx','/index.ts','/index.tsx','/__init__.py']; for(var i=0;i=0&&' ,])};\n\r'.indexOf(nextChar)>=0){ matchedNames.forEach(function(name){refs[name]++;}); }else if(opts.isPython&&prevChar==='@'&&!isDefinition){ matchedNames.forEach(function(name){refs[name]++;}); + }else if(opts.isRuby&&!isDefinition){ + // In Ruby a bare identifier that is not a local variable IS a + // method call (implicit receiver: `build_table.each`, `run_serial`). + // Mirror the Python tree-sitter policy: any non-definition + // occurrence of a known method name counts as a usage. + matchedNames.forEach(function(name){refs[name]++;}); } } @@ -3084,6 +3156,13 @@ return Parser.countCandidateCalls(cleanPascal,fnNames,{isPascal:true}); } + if(ext==='rb'||ext==='rake'){ + // Strip # comments (but not #{...} interpolation) so commented-out + // code does not count as a call site. + var cleanRuby=content.replace(/#(?!\{)[^\n]*/g,''); + return Parser.countCandidateCalls(cleanRuby,fnNames,{isRuby:true}); + } + if(isJS&&typeof acorn!=='undefined'){ try{ // Use Babel (real parser) to handle JSX and TypeScript @@ -3539,7 +3618,7 @@ function isArchitectureTestFile(path){ var p=String(path||'').toLowerCase().replace(/\\/g,'/'); - return /(^|\/)tests?\//.test(p)||/(^|\/)__tests__(\/|$)/.test(p)||/\.test\.(js|jsx|ts|tsx|mjs|cjs)$/.test(p)||/\.spec\.(js|jsx|ts|tsx|mjs|cjs)$/.test(p)||/\.smoke\.(js|mjs|cjs)$/.test(p); + return Parser.isTestFile(p)||/\.smoke\.(js|mjs|cjs)$/.test(p); } function isArchitectureFixtureFile(path){ @@ -5019,6 +5098,23 @@ var fn=entry[0],cnt=entry[1]; if(cnt<=0)return; var defs=resolveCallDefinitions(fn,file); + if(!defs.length){ + // The name IS called somewhere but the receiver's type (or + // import) could not be resolved to a single definition. + // For a dead-code report a false negative is far cheaper + // than a false positive, so mark every same-named + // definition as possibly called instead of dropping the + // call on the floor (polymorphic Ruby/duck-typed dispatch, + // multi-file JS globals). + var ambiguous=Parser.isPascal(file.path) + ?fnDefIndex.byPascalName[fn.toLowerCase().split('.').pop()]||[] + :fnDefIndex.byName[fn]||[]; + ambiguous.forEach(function(def){ + var st=fnStats[def.key]; + if(st)st.possiblyCalled=true; + }); + return; + } defs.forEach(function(def){ var stat=fnStats[def.key]; if(!stat)return; @@ -5067,9 +5163,17 @@ desc:'Files larger than 2 MB are listed but their contents are not parsed', items:oversizedFiles.map(function(file){return{name:file.name,file:file.path,size:file.size||0};}) }); + var fetchFailedFiles=analyzed.filter(function(file){return file.analysisSkipped==='fetch-failed';}); + if(fetchFailedFiles.length)issues.push({ + type:'critical', + title:fetchFailedFiles.length+' Files Not Fetched — Partial Analysis', + desc:'GitHub API requests failed (usually the unauthenticated rate limit of 60/hour). Every metric below is computed WITHOUT these files. Add a token, or use Open ZIP for a complete analysis.', + items:fetchFailedFiles.map(function(file){return{name:file.name,file:file.path,size:file.size||0};}) + }); var deadFns=Object.entries(fnStats).filter(function(x){ var stats=x[1],name=stats.name; if(stats.internal>0||stats.external>0)return false; + if(stats.possiblyCalled)return false; if(stats.isClassMethod)return false; if(!stats.isTopLevel)return false; if(stats.decorators&&stats.decorators.length>0)return false; @@ -5077,7 +5181,10 @@ var baseName=name.includes('.')?name.split('.').pop():name; if(baseName.startsWith('__')&&baseName.endsWith('__'))return false; if(baseName.startsWith('test_')||baseName==='setUp'||baseName==='tearDown'||baseName==='setUpClass'||baseName==='tearDownClass')return false; - if(stats.file&&(stats.file.includes('test_')||stats.file.includes('_test.')||stats.file.includes('/tests/')))return false; + // Ruby constructors are invoked via Class.new, never by name; module/class + // lifecycle hooks and test hooks are invoked by the runtime. + if(stats.file&&/\.(rb|rake)$/.test(stats.file)&&['initialize','included','extended','inherited','prepended','method_missing','respond_to_missing?','setup','teardown'].indexOf(baseName)>=0)return false; + if(stats.file&&(stats.file.includes('test_')||stats.file.includes('_test.')||stats.file.includes('/tests/')||Parser.isTestFile(stats.file)))return false; if((baseName==='upgrade'||baseName==='downgrade')&&stats.file&&(stats.file.includes('migration')||stats.file.includes('alembic')||stats.file.includes('versions')))return false; if(['main','create_app','make_app','get_app','setup','configure','register','on_startup','on_shutdown','lifespan'].indexOf(baseName)>=0)return false; if(stats.isExported&&stats.file&&/\.[jt]sx?$/.test(stats.file))return false; @@ -5094,13 +5201,23 @@ var highCoup=Object.entries(coupling).filter(function(x){return x[1]>8;}).sort(function(a,b){return b[1]-a[1];}); if(highCoup.length)issues.push({type:'warning',title:highCoup.length+' Highly Coupled',desc:'Files that import 8+ other files',items:highCoup.map(function(x){return{name:x[0].split('/').pop()+' ('+x[1]+' imports)',file:x[0],imports:x[1]};})}); - var connSet=new Set(conns.map(function(c){return c.source+'|'+c.target;})); + // Circular dependencies = actual import statements in BOTH directions. + // Mutual call-graph edges or markdown cross-links are not import cycles + // (a README linking to a guide that links back is working documentation, + // and a registry module calling back into its hosts is a deliberate design). var circular=[]; - conns.forEach(function(c){ - if(connSet.has(c.target+'|'+c.source)){ - var key=[c.source,c.target].sort().join('|'); - if(!circular.includes(key))circular.push(key); - } + var circularSeen=new Set(); + analyzed.forEach(function(file){ + var info=fileImportInfo[file.path]; + if(!info||!info.targets||typeof info.targets.forEach!=='function')return; + info.targets.forEach(function(target){ + if(!target||target===file.path)return; + var other=fileImportInfo[target]; + if(other&&other.targets&&typeof other.targets.has==='function'&&other.targets.has(file.path)){ + var key=[file.path,target].sort().join('|'); + if(!circularSeen.has(key)){circularSeen.add(key);circular.push(key);} + } + }); }); if(circular.length)issues.push({type:'critical',title:circular.length+' Circular Dependencies',desc:'Files that import each other',items:circular.map(function(p){var parts=p.split('|');return{name:parts.map(function(x){return x.split('/').pop();}).join(' ↔ '),files:parts};})}); @@ -5154,7 +5271,7 @@ issues.push({type:'critical',title:layerViolations.length+' Architecture Violations',desc:'Lower layers importing from higher layers',items:layerViolations.map(function(v){return{name:v.fromLayer+' → '+v.toLayer,file:v.from,toFile:v.to,fn:v.fn,suggestion:v.suggestion};})}); } var highComplexity=analyzed.filter(function(f){return f.complexity&&f.complexity.level==='critical';}).sort(function(a,b){return b.complexity.score-a.complexity.score;}); - if(highComplexity.length)issues.push({type:'warning',title:highComplexity.length+' High Complexity Files',desc:'Files with complexity score >30',items:highComplexity.map(function(f){return{name:f.name+' ('+f.complexity.score+')',file:f.path,score:f.complexity.score,lines:f.lines};})}); + if(highComplexity.length)issues.push({type:'warning',title:highComplexity.length+' High Complexity Files',desc:'Approximate cyclomatic complexity >30 (counts branch keywords and boolean operators per file)',items:highComplexity.map(function(f){return{name:f.name+' ('+f.complexity.score+')',file:f.path,score:f.complexity.score,lines:f.lines};})}); var dataObj={ files:analyzed, @@ -6198,9 +6315,13 @@ if(actualIsCode){ fns.forEach(function(fn){allFns.push(Object.assign({},fn,{folder:f.folder,layer:layer}));}); } + }else{ + // Content fetch failed (rate limit, network) — keep the + // file visible instead of silently shrinking the repo. + analyzed.push(makeFetchFailedAnalysisFile(f)); } processFile(i+1); - }).catch(function(){processFile(i+1);}); + }).catch(function(){analyzed.push(makeFetchFailedAnalysisFile(f));processFile(i+1);}); }else{ GitHub.getFile(p.owner,p.repo,f.path).then(function(content){ var layer=Parser.detectLayer(f.path); @@ -6208,7 +6329,7 @@ analyzed.push({path:f.path,name:f.name,folder:f.folder,content:content||'',functions:[],lines:lines,layer:layer,churn:0,isCode:false}); processFile(i+1); }).catch(function(){ - analyzed.push({path:f.path,name:f.name,folder:f.folder,content:'',functions:[],lines:0,layer:Parser.detectLayer(f.path),churn:0,isCode:false}); + analyzed.push(makeFetchFailedAnalysisFile(f)); processFile(i+1); }); } @@ -6223,6 +6344,10 @@ progress:setProgress, yieldFn:yieldToBrowser }); + var failedCount=analyzed.filter(function(af){return af.analysisSkipped==='fetch-failed';}).length; + if(failedCount>0){ + showNotification(failedCount+' of '+analyzed.length+' files could not be fetched (GitHub rate limit?). Results are PARTIAL — add a token or use Open ZIP for full analysis.','warning'); + } setData(dataObj); setExpandedPaths(new Set([''])); window.history.replaceState({},'',buildAppUrl(p.owner+'/'+p.repo,false));