diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cd0b564..7e9c4e75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Slop Detection Pipeline Architecture** - 3-phase detection pipeline with certainty-tagged findings (#107) + - **Phase 1** (always runs): Built-in regex patterns + multi-pass analyzers + - **Phase 2** (optional): CLI tool integration (jscpd, madge, escomplex) - if available + - **Phase 3**: LLM handoff with structured findings + - Certainty levels: HIGH (regex), MEDIUM (multi-pass), LOW (CLI tools) + - Thoroughness levels: quick (regex only), normal (+multi-pass), deep (+CLI) + - Mode inheritance from deslop-around: report (analyze only) vs apply (fix issues) + - New `runPipeline()` function in lib/patterns/pipeline.js + - New lib/patterns/cli-enhancers.js for optional tool detection + - deslop-work.md agent updated to use pipeline orchestrator + - Graceful degradation when CLI tools not installed + - **Buzzword Inflation Detection** - New project-level analyzer for `/deslop-around` command (#113) - Detects quality claims in documentation without supporting code evidence - 6 buzzword categories: production, enterprise, security, scale, reliability, completeness diff --git a/README.md b/README.md index f670474a..c068e392 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Ship your code from commit to production with full validation and state integrat ### `/deslop-around` - AI Slop Cleanup -Remove debugging code, old TODOs, and AI slop from your codebase. +Remove debugging code, old TODOs, and AI slop from your codebase with a 3-phase detection pipeline. ```bash /deslop-around # Report mode - analyze only @@ -160,6 +160,20 @@ Remove debugging code, old TODOs, and AI slop from your codebase. /deslop-around apply src/ 10 # Fix up to 10 issues in src/ ``` +**Architecture:** +- **Phase 1** - Built-in regex patterns (HIGH certainty) +- **Phase 2** - Multi-pass analyzers (MEDIUM certainty) +- **Phase 3** - Optional CLI tools (LOW certainty, graceful degradation) + - JavaScript/TypeScript: jscpd, madge, escomplex + - Python: pylint, radon + - Go: golangci-lint + - Rust: clippy + +**Thoroughness levels:** +- `quick` - Phase 1 only (fastest) +- `normal` - Phase 1 + Phase 2 (default) +- `deep` - Phase 1 + Phase 2 + Phase 3 (if tools available) + **Detects:** - Console debugging (`console.log`, `print()`, `dbg!()`) - Old TODOs and commented code @@ -170,6 +184,7 @@ Remove debugging code, old TODOs, and AI slop from your codebase. - Phantom references (issue/PR mentions, file path references in comments) - Infrastructure components configured but never used (unused DB clients, caches, API clients) - Code smells: boolean blindness, message chains, mutable globals, dead code, shotgun surgery +- Buzzword inflation (quality claims without evidence) --- diff --git a/__tests__/cli-enhancers.test.js b/__tests__/cli-enhancers.test.js new file mode 100644 index 00000000..2c835af0 --- /dev/null +++ b/__tests__/cli-enhancers.test.js @@ -0,0 +1,543 @@ +/** + * Tests for cli-enhancers.js + * Optional CLI tool integration for slop detection pipeline + */ + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const { + detectAvailableTools, + detectProjectLanguages, + getToolsForLanguages, + getToolAvailabilityForRepo, + runDuplicateDetection, + runDependencyAnalysis, + runComplexityAnalysis, + getMissingToolsMessage, + getToolDefinitions, + getSupportedLanguages, + clearCache, + isToolAvailable, + CLI_TOOLS, + SUPPORTED_LANGUAGES +} = require('../lib/patterns/cli-enhancers'); + +describe('cli-enhancers', () => { + // Clear cache before each test + beforeEach(() => { + clearCache(); + }); + + describe('SUPPORTED_LANGUAGES', () => { + it('should include javascript, typescript, python, rust, go', () => { + expect(SUPPORTED_LANGUAGES).toContain('javascript'); + expect(SUPPORTED_LANGUAGES).toContain('typescript'); + expect(SUPPORTED_LANGUAGES).toContain('python'); + expect(SUPPORTED_LANGUAGES).toContain('rust'); + expect(SUPPORTED_LANGUAGES).toContain('go'); + }); + + it('should have exactly 5 supported languages', () => { + expect(SUPPORTED_LANGUAGES.length).toBe(5); + }); + }); + + describe('CLI_TOOLS constants', () => { + it('should have JavaScript/TypeScript tools', () => { + expect(CLI_TOOLS.jscpd).toBeDefined(); + expect(CLI_TOOLS.jscpd.languages).toContain('javascript'); + expect(CLI_TOOLS.jscpd.languages).toContain('typescript'); + + expect(CLI_TOOLS.madge).toBeDefined(); + expect(CLI_TOOLS.madge.languages).toContain('javascript'); + expect(CLI_TOOLS.madge.languages).toContain('typescript'); + + expect(CLI_TOOLS.escomplex).toBeDefined(); + expect(CLI_TOOLS.escomplex.languages).toContain('javascript'); + }); + + it('should have Python tools', () => { + expect(CLI_TOOLS.pylint).toBeDefined(); + expect(CLI_TOOLS.pylint.languages).toContain('python'); + + expect(CLI_TOOLS.radon).toBeDefined(); + expect(CLI_TOOLS.radon.languages).toContain('python'); + }); + + it('should have Go tools', () => { + expect(CLI_TOOLS.golangci_lint).toBeDefined(); + expect(CLI_TOOLS.golangci_lint.languages).toContain('go'); + }); + + it('should have Rust tools', () => { + expect(CLI_TOOLS.clippy).toBeDefined(); + expect(CLI_TOOLS.clippy.languages).toContain('rust'); + }); + + it('each tool should have required fields', () => { + for (const tool of Object.values(CLI_TOOLS)) { + expect(tool.name).toBeDefined(); + expect(tool.description).toBeDefined(); + expect(tool.checkCommand).toBeDefined(); + expect(tool.installHint).toBeDefined(); + expect(Array.isArray(tool.languages)).toBe(true); + expect(tool.languages.length).toBeGreaterThan(0); + } + }); + + it('jscpd should support all languages (cross-language tool)', () => { + expect(CLI_TOOLS.jscpd.languages).toContain('javascript'); + expect(CLI_TOOLS.jscpd.languages).toContain('typescript'); + expect(CLI_TOOLS.jscpd.languages).toContain('python'); + expect(CLI_TOOLS.jscpd.languages).toContain('go'); + expect(CLI_TOOLS.jscpd.languages).toContain('rust'); + }); + }); + + describe('isToolAvailable', () => { + it('should return true for available commands', () => { + // node --version should always be available in test environment + const result = isToolAvailable('node --version'); + expect(result).toBe(true); + }); + + it('should return false for unavailable commands', () => { + const result = isToolAvailable('nonexistent_tool_xyz_123 --version'); + expect(result).toBe(false); + }); + + it('should handle command execution errors gracefully', () => { + // Invalid command should return false, not throw + const result = isToolAvailable(''); + expect(result).toBe(false); + }); + }); + + describe('detectProjectLanguages', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-enhancers-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should detect JavaScript from package.json', () => { + fs.writeFileSync(path.join(tempDir, 'package.json'), '{}'); + const langs = detectProjectLanguages(tempDir); + expect(langs).toContain('javascript'); + }); + + it('should detect TypeScript from tsconfig.json', () => { + fs.writeFileSync(path.join(tempDir, 'tsconfig.json'), '{}'); + const langs = detectProjectLanguages(tempDir); + expect(langs).toContain('typescript'); + }); + + it('should detect Python from requirements.txt', () => { + fs.writeFileSync(path.join(tempDir, 'requirements.txt'), 'flask\n'); + const langs = detectProjectLanguages(tempDir); + expect(langs).toContain('python'); + }); + + it('should detect Go from go.mod', () => { + fs.writeFileSync(path.join(tempDir, 'go.mod'), 'module test\n'); + const langs = detectProjectLanguages(tempDir); + expect(langs).toContain('go'); + }); + + it('should detect Rust from Cargo.toml', () => { + fs.writeFileSync(path.join(tempDir, 'Cargo.toml'), '[package]\n'); + const langs = detectProjectLanguages(tempDir); + expect(langs).toContain('rust'); + }); + + it('should detect multiple languages', () => { + fs.writeFileSync(path.join(tempDir, 'package.json'), '{}'); + fs.writeFileSync(path.join(tempDir, 'requirements.txt'), 'flask\n'); + const langs = detectProjectLanguages(tempDir); + expect(langs).toContain('javascript'); + expect(langs).toContain('python'); + }); + + it('should fallback to file extension scanning', () => { + fs.writeFileSync(path.join(tempDir, 'main.py'), 'print("hello")'); + const langs = detectProjectLanguages(tempDir); + expect(langs).toContain('python'); + }); + + it('should default to javascript if nothing detected', () => { + const langs = detectProjectLanguages(tempDir); + expect(langs).toContain('javascript'); + }); + + it('should only return supported languages', () => { + const langs = detectProjectLanguages(tempDir); + for (const lang of langs) { + expect(SUPPORTED_LANGUAGES).toContain(lang); + } + }); + }); + + describe('getToolsForLanguages', () => { + it('should return JS tools for javascript', () => { + const tools = getToolsForLanguages(['javascript']); + expect(tools.jscpd).toBeDefined(); + expect(tools.madge).toBeDefined(); + expect(tools.escomplex).toBeDefined(); + }); + + it('should return Python tools for python', () => { + const tools = getToolsForLanguages(['python']); + expect(tools.pylint).toBeDefined(); + expect(tools.radon).toBeDefined(); + expect(tools.jscpd).toBeDefined(); // jscpd supports python too + }); + + it('should return Go tools for go', () => { + const tools = getToolsForLanguages(['go']); + expect(tools.golangci_lint).toBeDefined(); + expect(tools.jscpd).toBeDefined(); // jscpd supports go too + }); + + it('should return Rust tools for rust', () => { + const tools = getToolsForLanguages(['rust']); + expect(tools.clippy).toBeDefined(); + expect(tools.jscpd).toBeDefined(); // jscpd supports rust too + }); + + it('should return combined tools for multiple languages', () => { + const tools = getToolsForLanguages(['javascript', 'python']); + // JS tools + expect(tools.madge).toBeDefined(); + // Python tools + expect(tools.pylint).toBeDefined(); + }); + + it('should return empty object for unknown language', () => { + const tools = getToolsForLanguages(['brainfuck']); + expect(Object.keys(tools).length).toBe(0); + }); + }); + + describe('detectAvailableTools', () => { + it('should return object with tool keys when no languages specified', () => { + const tools = detectAvailableTools(); + expect(typeof tools).toBe('object'); + // Should include all tools + expect(Object.keys(tools).length).toBe(Object.keys(CLI_TOOLS).length); + }); + + it('should filter to JS tools when javascript specified', () => { + const tools = detectAvailableTools(['javascript']); + expect(tools).toHaveProperty('jscpd'); + expect(tools).toHaveProperty('madge'); + expect(tools).toHaveProperty('escomplex'); + // Should not have python-only tools + expect(tools).not.toHaveProperty('pylint'); + }); + + it('should filter to Python tools when python specified', () => { + const tools = detectAvailableTools(['python']); + expect(tools).toHaveProperty('pylint'); + expect(tools).toHaveProperty('radon'); + expect(tools).toHaveProperty('jscpd'); // jscpd supports python + // Should not have JS-only tools + expect(tools).not.toHaveProperty('madge'); + }); + + it('should return boolean values for each tool', () => { + const tools = detectAvailableTools(['javascript']); + for (const value of Object.values(tools)) { + expect(typeof value).toBe('boolean'); + } + }); + + it('should use cache when repoPath provided', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cache-test-')); + try { + // First call - populates cache + const tools1 = detectAvailableTools(['javascript'], tempDir); + // Second call - should use cache + const tools2 = detectAvailableTools(['javascript'], tempDir); + expect(tools2).toEqual(tools1); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + }); + + describe('getToolAvailabilityForRepo', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-enhancers-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should return detected languages', () => { + fs.writeFileSync(path.join(tempDir, 'package.json'), '{}'); + const result = getToolAvailabilityForRepo(tempDir); + expect(result.languages).toContain('javascript'); + }); + + it('should return available tools object', () => { + fs.writeFileSync(path.join(tempDir, 'package.json'), '{}'); + const result = getToolAvailabilityForRepo(tempDir); + expect(result.available).toBeDefined(); + expect(typeof result.available).toBe('object'); + }); + + it('should return missing tools array', () => { + fs.writeFileSync(path.join(tempDir, 'package.json'), '{}'); + const result = getToolAvailabilityForRepo(tempDir); + expect(Array.isArray(result.missing)).toBe(true); + }); + + it('should use cache on subsequent calls', () => { + fs.writeFileSync(path.join(tempDir, 'package.json'), '{}'); + const result1 = getToolAvailabilityForRepo(tempDir); + const result2 = getToolAvailabilityForRepo(tempDir); + expect(result2.languages).toEqual(result1.languages); + }); + + it('should refresh cache when forceRefresh is true', () => { + fs.writeFileSync(path.join(tempDir, 'package.json'), '{}'); + getToolAvailabilityForRepo(tempDir); // Initial cache + // Add Python + fs.writeFileSync(path.join(tempDir, 'requirements.txt'), 'flask\n'); + // Without force refresh, should still show only JS (cached) + const cachedResult = getToolAvailabilityForRepo(tempDir); + expect(cachedResult.languages).not.toContain('python'); + // With force refresh, should detect Python too + const refreshedResult = getToolAvailabilityForRepo(tempDir, { forceRefresh: true }); + expect(refreshedResult.languages).toContain('python'); + }); + }); + + describe('runDuplicateDetection', () => { + it('should return null if jscpd not available', () => { + const result = runDuplicateDetection('/nonexistent/path'); + // Either null (tool not available) or array (tool available) + expect(result === null || Array.isArray(result)).toBe(true); + }); + + it('should accept options', () => { + const result = runDuplicateDetection('/nonexistent/path', { + minLines: 10, + minTokens: 100 + }); + expect(result === null || Array.isArray(result)).toBe(true); + }); + }); + + describe('runDependencyAnalysis', () => { + it('should return null if madge not available', () => { + const result = runDependencyAnalysis('/nonexistent/path'); + expect(result === null || Array.isArray(result)).toBe(true); + }); + + it('should accept entry option', () => { + const result = runDependencyAnalysis('/nonexistent/path', { + entry: 'src/index.js' + }); + expect(result === null || Array.isArray(result)).toBe(true); + }); + }); + + describe('runComplexityAnalysis', () => { + it('should return null if escomplex not available', () => { + const result = runComplexityAnalysis('/nonexistent/path', ['app.js']); + expect(result === null || Array.isArray(result)).toBe(true); + }); + + it('should skip non-JS files', () => { + const result = runComplexityAnalysis('/nonexistent/path', ['app.py', 'main.go']); + // Should return null since no JS files to analyze + expect(result === null || Array.isArray(result)).toBe(true); + }); + }); + + describe('getMissingToolsMessage', () => { + it('should return empty string for empty array', () => { + const message = getMissingToolsMessage([]); + expect(message).toBe(''); + }); + + it('should return empty string for null/undefined', () => { + expect(getMissingToolsMessage(null)).toBe(''); + expect(getMissingToolsMessage(undefined)).toBe(''); + }); + + it('should format message for single missing tool', () => { + const message = getMissingToolsMessage(['jscpd']); + expect(message).toContain('jscpd'); + expect(message).toContain('npm install -g jscpd'); + expect(message).toContain('Enhanced Analysis Available'); + }); + + it('should format message for multiple missing tools', () => { + const message = getMissingToolsMessage(['jscpd', 'madge', 'escomplex']); + expect(message).toContain('jscpd'); + expect(message).toContain('madge'); + expect(message).toContain('escomplex'); + }); + + it('should include detected languages when provided', () => { + const message = getMissingToolsMessage(['pylint'], ['python']); + expect(message).toContain('python'); + expect(message).toContain('Detected project languages'); + }); + + it('should skip unknown tools', () => { + const message = getMissingToolsMessage(['unknown_tool']); + expect(message).toBe(''); + }); + + it('should include optional notice', () => { + const message = getMissingToolsMessage(['jscpd']); + expect(message).toContain('optional'); + }); + }); + + describe('getToolDefinitions', () => { + it('should return copy of CLI_TOOLS', () => { + const definitions = getToolDefinitions(); + expect(definitions).toHaveProperty('jscpd'); + expect(definitions).toHaveProperty('madge'); + expect(definitions).toHaveProperty('escomplex'); + }); + + it('should return independent copy', () => { + const definitions = getToolDefinitions(); + definitions.jscpd = null; + // Original should be unchanged + expect(CLI_TOOLS.jscpd).toBeDefined(); + }); + }); + + describe('getSupportedLanguages', () => { + it('should return copy of SUPPORTED_LANGUAGES', () => { + const langs = getSupportedLanguages(); + expect(langs).toContain('javascript'); + expect(langs).toContain('python'); + }); + + it('should return independent copy', () => { + const langs = getSupportedLanguages(); + langs.push('brainfuck'); + expect(SUPPORTED_LANGUAGES).not.toContain('brainfuck'); + }); + }); + + describe('clearCache', () => { + it('should clear the tool cache', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cache-test-')); + try { + fs.writeFileSync(path.join(tempDir, 'package.json'), '{}'); + // Populate cache + getToolAvailabilityForRepo(tempDir); + // Clear cache + clearCache(); + // Add Python and refresh - should detect it now + fs.writeFileSync(path.join(tempDir, 'requirements.txt'), 'flask\n'); + const result = getToolAvailabilityForRepo(tempDir); + expect(result.languages).toContain('python'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + }); + + describe('graceful degradation', () => { + it('runDuplicateDetection should not throw when tool unavailable', () => { + expect(() => { + runDuplicateDetection('/some/path'); + }).not.toThrow(); + }); + + it('runDependencyAnalysis should not throw when tool unavailable', () => { + expect(() => { + runDependencyAnalysis('/some/path'); + }).not.toThrow(); + }); + + it('runComplexityAnalysis should not throw when tool unavailable', () => { + expect(() => { + runComplexityAnalysis('/some/path', ['app.js']); + }).not.toThrow(); + }); + }); + + describe('install hints', () => { + it('JS tools should use npm install', () => { + expect(CLI_TOOLS.jscpd.installHint).toContain('npm install'); + expect(CLI_TOOLS.madge.installHint).toContain('npm install'); + expect(CLI_TOOLS.escomplex.installHint).toContain('npm install'); + }); + + it('Python tools should use pip install', () => { + expect(CLI_TOOLS.pylint.installHint).toContain('pip install'); + expect(CLI_TOOLS.radon.installHint).toContain('pip install'); + }); + + it('Go tools should use go install', () => { + expect(CLI_TOOLS.golangci_lint.installHint).toContain('go install'); + }); + + it('Rust tools should use rustup', () => { + expect(CLI_TOOLS.clippy.installHint).toContain('rustup'); + }); + }); + + describe('command injection prevention', () => { + it('runDuplicateDetection should handle paths with shell metacharacters safely', () => { + // These paths contain shell injection attempts + const dangerousPaths = [ + '/path/with/$HOME/injection', + '/path/with/`whoami`/injection', + '/path/with/$(id)/injection', + '/path/with/"quotes"/injection' + ]; + + // Should not throw - paths are escaped internally + for (const path of dangerousPaths) { + expect(() => { + runDuplicateDetection(path); + }).not.toThrow(); + } + }); + + it('runDependencyAnalysis should handle paths with shell metacharacters safely', () => { + const dangerousPaths = [ + '/path/with/$HOME/injection', + '/path/with/`whoami`/injection', + '/path/with/$(id)/injection' + ]; + + for (const path of dangerousPaths) { + expect(() => { + runDependencyAnalysis(path); + }).not.toThrow(); + } + }); + + it('runComplexityAnalysis should handle file paths with shell metacharacters safely', () => { + const dangerousFiles = [ + '/path/with/$HOME/file.js', + '/path/with/`whoami`/file.js', + '/path/with/$(id)/file.js' + ]; + + expect(() => { + runComplexityAnalysis('/safe/repo', dangerousFiles); + }).not.toThrow(); + }); + }); +}); diff --git a/__tests__/pipeline.test.js b/__tests__/pipeline.test.js new file mode 100644 index 00000000..d4040e7f --- /dev/null +++ b/__tests__/pipeline.test.js @@ -0,0 +1,626 @@ +/** + * Tests for pipeline.js + * Slop detection pipeline orchestrator + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const { + runPipeline, + runPhase1, + runMultiPassAnalyzers, + buildSummary, + formatHandoffPrompt, + CERTAINTY, + THOROUGHNESS +} = require('../lib/patterns/pipeline'); + +describe('pipeline', () => { + // Test directory setup + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pipeline-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe('CERTAINTY constants', () => { + it('should have all certainty levels defined', () => { + expect(CERTAINTY.HIGH).toBe('HIGH'); + expect(CERTAINTY.MEDIUM).toBe('MEDIUM'); + expect(CERTAINTY.LOW).toBe('LOW'); + }); + }); + + describe('THOROUGHNESS constants', () => { + it('should have all thoroughness levels defined', () => { + expect(THOROUGHNESS.QUICK).toBe('quick'); + expect(THOROUGHNESS.NORMAL).toBe('normal'); + expect(THOROUGHNESS.DEEP).toBe('deep'); + }); + }); + + describe('runPhase1', () => { + it('should detect console.log statements', () => { + fs.writeFileSync( + path.join(tmpDir, 'app.js'), + 'function test() {\n console.log("debug");\n return 1;\n}' + ); + + const findings = runPhase1(tmpDir, ['app.js'], null); + + expect(findings.length).toBeGreaterThan(0); + expect(findings[0].patternName).toBe('console_debugging'); + expect(findings[0].certainty).toBe(CERTAINTY.HIGH); + expect(findings[0].phase).toBe(1); + }); + + it('should detect placeholder text', () => { + fs.writeFileSync( + path.join(tmpDir, 'app.js'), + 'const text = "lorem ipsum dolor sit amet";\n' + ); + + const findings = runPhase1(tmpDir, ['app.js'], null); + + const placeholderFindings = findings.filter(f => f.patternName === 'placeholder_text'); + expect(placeholderFindings.length).toBeGreaterThan(0); + }); + + it('should filter by language', () => { + fs.writeFileSync( + path.join(tmpDir, 'app.js'), + 'console.log("debug");\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'app.py'), + 'print("debug")\n' + ); + + const jsFindings = runPhase1(tmpDir, ['app.js', 'app.py'], 'javascript'); + + // Should only find JS console.log, not Python print + const jsConsole = jsFindings.filter(f => f.patternName === 'console_debugging'); + expect(jsConsole.length).toBe(1); + expect(jsConsole[0].file).toBe('app.js'); + }); + + it('should skip excluded files', () => { + fs.writeFileSync( + path.join(tmpDir, 'app.test.js'), + 'console.log("debug in test");\n' + ); + + const findings = runPhase1(tmpDir, ['app.test.js'], null); + + // Test files should be excluded for console_debugging pattern + const consoleFindings = findings.filter(f => f.patternName === 'console_debugging'); + expect(consoleFindings.length).toBe(0); + }); + + it('should include line number and content', () => { + fs.writeFileSync( + path.join(tmpDir, 'app.js'), + 'function foo() {\n console.log("test");\n}' + ); + + const findings = runPhase1(tmpDir, ['app.js'], null); + + expect(findings[0].line).toBe(2); + expect(findings[0].content).toContain('console.log'); + }); + + it('should handle empty files gracefully', () => { + fs.writeFileSync(path.join(tmpDir, 'empty.js'), ''); + + const findings = runPhase1(tmpDir, ['empty.js'], null); + + expect(findings.length).toBe(0); + }); + + it('should handle unreadable files gracefully', () => { + const findings = runPhase1(tmpDir, ['nonexistent.js'], null); + + expect(findings.length).toBe(0); + }); + }); + + describe('runMultiPassAnalyzers', () => { + it('should detect excessive JSDoc', () => { + // JSDoc: 15 non-empty lines, Function: 4 code lines = 15/4 = 3.75x (exceeds 3.0 max) + const code = ` +/** + * Add two numbers together with detailed documentation + * This function performs addition + * Line 3 with more explanation + * Line 4 describes parameters + * @param {number} a - First number to add + * @param {number} b - Second number to add + * @returns {number} The sum of a and b + * Line 8 with additional context + * Line 9 more details about the function + * Line 10 even more information + * Line 11 some more text here + * Line 12 additional notes + * Line 13 edge cases documented + * Line 14 performance considerations + * Line 15 final closing notes + */ +function add(a, b) { + const sum = a + b; + const validated = sum; + console.log(validated); + return validated; +}`; + fs.writeFileSync(path.join(tmpDir, 'math.js'), code); + + const findings = runMultiPassAnalyzers(tmpDir, ['math.js']); + + const docRatioFindings = findings.filter(f => f.patternName === 'doc_code_ratio_js'); + expect(docRatioFindings.length).toBeGreaterThan(0); + expect(docRatioFindings[0].certainty).toBe(CERTAINTY.MEDIUM); + }); + + it('should detect excessive inline comments', () => { + // Comments: 8 lines, Code: 4 lines = 8/4 = 2x (matches 2.0 maxCommentRatio threshold) + // Need to exceed the threshold, so making it higher + const code = ` +function process(data) { + // This is a comment explaining the function + // Another comment with more details + // Yet another comment about edge cases + // Still more comments about implementation + // Even more explanation about approach + // So much text here describing behavior + // Really explaining everything in detail + // One more comment to push over threshold + // And another for good measure + const result = data.trim(); + const processed = result.toLowerCase(); + const final = processed.replace(/\\s+/g, ' '); + return final; +}`; + fs.writeFileSync(path.join(tmpDir, 'processor.js'), code); + + const findings = runMultiPassAnalyzers(tmpDir, ['processor.js']); + + const verbosityFindings = findings.filter(f => f.patternName === 'verbosity_ratio'); + expect(verbosityFindings.length).toBeGreaterThan(0); + }); + + it('should skip non-JS files for JSDoc analysis', () => { + fs.writeFileSync( + path.join(tmpDir, 'app.py'), + '"""\nVery long docstring\n"""\ndef foo():\n pass\n' + ); + + const findings = runMultiPassAnalyzers(tmpDir, ['app.py']); + + const docRatioFindings = findings.filter(f => f.patternName === 'doc_code_ratio_js'); + expect(docRatioFindings.length).toBe(0); + }); + }); + + describe('buildSummary', () => { + it('should count findings by severity', () => { + const findings = [ + { severity: 'high', certainty: 'HIGH', phase: 1, autoFix: 'remove', patternName: 'a' }, + { severity: 'medium', certainty: 'HIGH', phase: 1, autoFix: 'flag', patternName: 'b' }, + { severity: 'medium', certainty: 'MEDIUM', phase: 1, autoFix: 'flag', patternName: 'c' }, + { severity: 'low', certainty: 'LOW', phase: 2, autoFix: 'none', patternName: 'd' } + ]; + + const summary = buildSummary(findings); + + expect(summary.total).toBe(4); + expect(summary.bySeverity.high).toBe(1); + expect(summary.bySeverity.medium).toBe(2); + expect(summary.bySeverity.low).toBe(1); + }); + + it('should count findings by certainty', () => { + const findings = [ + { severity: 'high', certainty: 'HIGH', phase: 1, autoFix: 'remove', patternName: 'a' }, + { severity: 'medium', certainty: 'HIGH', phase: 1, autoFix: 'flag', patternName: 'b' }, + { severity: 'medium', certainty: 'MEDIUM', phase: 1, autoFix: 'flag', patternName: 'c' }, + { severity: 'low', certainty: 'LOW', phase: 2, autoFix: 'none', patternName: 'd' } + ]; + + const summary = buildSummary(findings); + + expect(summary.byCertainty.HIGH).toBe(2); + expect(summary.byCertainty.MEDIUM).toBe(1); + expect(summary.byCertainty.LOW).toBe(1); + }); + + it('should count findings by phase', () => { + const findings = [ + { severity: 'high', certainty: 'HIGH', phase: 1, autoFix: 'remove', patternName: 'a' }, + { severity: 'medium', certainty: 'HIGH', phase: 1, autoFix: 'flag', patternName: 'b' }, + { severity: 'low', certainty: 'LOW', phase: 2, autoFix: 'none', patternName: 'c' } + ]; + + const summary = buildSummary(findings); + + expect(summary.byPhase[1]).toBe(2); + expect(summary.byPhase[2]).toBe(1); + }); + + it('should count findings by autoFix strategy', () => { + const findings = [ + { severity: 'high', certainty: 'HIGH', phase: 1, autoFix: 'remove', patternName: 'a' }, + { severity: 'medium', certainty: 'HIGH', phase: 1, autoFix: 'flag', patternName: 'b' }, + { severity: 'medium', certainty: 'MEDIUM', phase: 1, autoFix: 'flag', patternName: 'c' } + ]; + + const summary = buildSummary(findings); + + expect(summary.byAutoFix.remove).toBe(1); + expect(summary.byAutoFix.flag).toBe(2); + }); + + it('should track top patterns', () => { + const findings = [ + { severity: 'high', certainty: 'HIGH', phase: 1, autoFix: 'remove', patternName: 'console_debugging' }, + { severity: 'high', certainty: 'HIGH', phase: 1, autoFix: 'remove', patternName: 'console_debugging' }, + { severity: 'medium', certainty: 'HIGH', phase: 1, autoFix: 'flag', patternName: 'placeholder_text' } + ]; + + const summary = buildSummary(findings); + + expect(summary.topPatterns.console_debugging).toBe(2); + expect(summary.topPatterns.placeholder_text).toBe(1); + }); + + it('should handle empty findings array', () => { + const summary = buildSummary([]); + + expect(summary.total).toBe(0); + expect(summary.bySeverity.high).toBe(0); + }); + }); + + describe('formatHandoffPrompt', () => { + it('should return no issues message for empty findings', () => { + const prompt = formatHandoffPrompt([], 'report'); + + expect(prompt).toContain('No issues detected'); + }); + + it('should include mode in prompt', () => { + const findings = [ + { file: 'a.js', line: 1, certainty: 'HIGH', description: 'Test', autoFix: 'remove', severity: 'medium' } + ]; + + const reportPrompt = formatHandoffPrompt(findings, 'report'); + const applyPrompt = formatHandoffPrompt(findings, 'apply'); + + expect(reportPrompt).toContain('Mode: **report**'); + expect(applyPrompt).toContain('Mode: **apply**'); + }); + + it('should group findings by certainty', () => { + const findings = [ + { file: 'a.js', line: 1, certainty: 'HIGH', description: 'High certainty', autoFix: 'remove', severity: 'high' }, + { file: 'b.js', line: 2, certainty: 'MEDIUM', description: 'Medium certainty', autoFix: 'flag', severity: 'medium' }, + { file: 'c.js', line: 3, certainty: 'LOW', description: 'Low certainty', autoFix: 'flag', severity: 'low' } + ]; + + const prompt = formatHandoffPrompt(findings, 'report'); + + expect(prompt).toContain('HIGH Certainty'); + expect(prompt).toContain('MEDIUM Certainty'); + expect(prompt).toContain('LOW Certainty'); + }); + + it('should include action guidance for apply mode', () => { + const findings = [ + { file: 'a.js', line: 1, certainty: 'HIGH', description: 'Test', autoFix: 'remove', severity: 'high' } + ]; + + const prompt = formatHandoffPrompt(findings, 'apply'); + + expect(prompt).toContain('Apply fixes directly'); + }); + + it('should include action summary', () => { + const findings = [ + { file: 'a.js', line: 1, certainty: 'HIGH', description: 'Test1', autoFix: 'remove', severity: 'high' }, + { file: 'b.js', line: 2, certainty: 'HIGH', description: 'Test2', autoFix: 'flag', severity: 'medium' } + ]; + + const prompt = formatHandoffPrompt(findings, 'report'); + + expect(prompt).toContain('Auto-fixable: 1'); + expect(prompt).toContain('Needs manual review: 1'); + }); + + it('should group findings by file', () => { + const findings = [ + { file: 'app.js', line: 1, certainty: 'HIGH', description: 'Issue 1', autoFix: 'remove', severity: 'high' }, + { file: 'app.js', line: 5, certainty: 'HIGH', description: 'Issue 2', autoFix: 'remove', severity: 'high' }, + { file: 'utils.js', line: 3, certainty: 'HIGH', description: 'Issue 3', autoFix: 'flag', severity: 'medium' } + ]; + + const prompt = formatHandoffPrompt(findings, 'report'); + + expect(prompt).toContain('**app.js**'); + expect(prompt).toContain('**utils.js**'); + expect(prompt).toContain('L1:'); + expect(prompt).toContain('L5:'); + }); + + it('should include autoFix tags for fixable issues', () => { + const findings = [ + { file: 'a.js', line: 1, certainty: 'HIGH', description: 'Test', autoFix: 'remove', severity: 'high' } + ]; + + const prompt = formatHandoffPrompt(findings, 'report'); + + expect(prompt).toContain('[remove]'); + }); + + it('should not include autoFix tags for flag/none', () => { + const findings = [ + { file: 'a.js', line: 1, certainty: 'HIGH', description: 'Test', autoFix: 'flag', severity: 'high' } + ]; + + const prompt = formatHandoffPrompt(findings, 'report'); + + expect(prompt).not.toContain('[flag]'); + }); + }); + + describe('runPipeline', () => { + it('should return correct structure', () => { + fs.writeFileSync( + path.join(tmpDir, 'app.js'), + 'console.log("test");\n' + ); + + const result = runPipeline(tmpDir, { + thoroughness: 'quick', + mode: 'report' + }); + + expect(result).toHaveProperty('findings'); + expect(result).toHaveProperty('summary'); + expect(result).toHaveProperty('phase3Prompt'); + expect(result).toHaveProperty('missingTools'); + expect(result).toHaveProperty('metadata'); + }); + + it('should include metadata', () => { + const result = runPipeline(tmpDir, { + thoroughness: 'quick', + mode: 'report' + }); + + expect(result.metadata.repoPath).toBe(tmpDir); + expect(result.metadata.thoroughness).toBe('quick'); + expect(result.metadata.mode).toBe('report'); + expect(result.metadata.timestamp).toBeDefined(); + }); + + it('should detect findings in quick mode', () => { + fs.writeFileSync( + path.join(tmpDir, 'app.js'), + 'function test() {\n console.log("debug");\n}' + ); + + const result = runPipeline(tmpDir, { + thoroughness: 'quick', + targetFiles: ['app.js'] + }); + + expect(result.findings.length).toBeGreaterThan(0); + // Quick mode only runs Phase 1 + expect(result.findings.every(f => f.phase === 1)).toBe(true); + }); + + it('should run multi-pass analyzers in normal mode', () => { + const code = ` +/** + * Excessive docs + * Line 1 + * Line 2 + * Line 3 + * Line 4 + * Line 5 + * Line 6 + * Line 7 + * Line 8 + */ +function foo() { + return 1; +}`; + fs.writeFileSync(path.join(tmpDir, 'test.js'), code); + + const result = runPipeline(tmpDir, { + thoroughness: 'normal', + targetFiles: ['test.js'] + }); + + // Normal mode includes multi-pass analyzers (may or may not have findings depending on thresholds) + expect(result.findings).toBeDefined(); + expect(result.metadata.thoroughness).toBe('normal'); + }); + + it('should track missing tools in deep mode', () => { + const result = runPipeline(tmpDir, { + thoroughness: 'deep', + cliTools: { jscpd: false, madge: false, escomplex: false } + }); + + expect(result.missingTools).toContain('jscpd'); + expect(result.missingTools).toContain('madge'); + expect(result.missingTools).toContain('escomplex'); + }); + + it('should use default options', () => { + const result = runPipeline(tmpDir); + + expect(result.metadata.thoroughness).toBe('normal'); + expect(result.metadata.mode).toBe('report'); + }); + + it('should filter by specific files', () => { + fs.writeFileSync(path.join(tmpDir, 'a.js'), 'console.log("a");\n'); + fs.writeFileSync(path.join(tmpDir, 'b.js'), 'console.log("b");\n'); + + const result = runPipeline(tmpDir, { + thoroughness: 'quick', + targetFiles: ['a.js'] + }); + + const filesWithFindings = [...new Set(result.findings.map(f => f.file))]; + expect(filesWithFindings).toContain('a.js'); + expect(filesWithFindings).not.toContain('b.js'); + }); + + it('should generate phase3Prompt with findings', () => { + fs.writeFileSync( + path.join(tmpDir, 'app.js'), + 'console.log("debug");\n' + ); + + const result = runPipeline(tmpDir, { + thoroughness: 'quick', + targetFiles: ['app.js'], + mode: 'apply' + }); + + expect(result.phase3Prompt).toContain('Mode: **apply**'); + expect(result.phase3Prompt).toContain('HIGH Certainty'); + }); + }); + + describe('mode inheritance', () => { + it('should pass mode to handoff prompt', () => { + fs.writeFileSync(path.join(tmpDir, 'a.js'), 'console.log("test");'); + + const reportResult = runPipeline(tmpDir, { + thoroughness: 'quick', + targetFiles: ['a.js'], + mode: 'report' + }); + + const applyResult = runPipeline(tmpDir, { + thoroughness: 'quick', + targetFiles: ['a.js'], + mode: 'apply' + }); + + expect(reportResult.phase3Prompt).toContain('Mode: **report**'); + expect(applyResult.phase3Prompt).toContain('Mode: **apply**'); + }); + }); + + describe('certainty tagging', () => { + it('should tag Phase 1 regex matches as HIGH certainty', () => { + fs.writeFileSync(path.join(tmpDir, 'a.js'), 'console.log("test");'); + + const result = runPipeline(tmpDir, { + thoroughness: 'quick', + targetFiles: ['a.js'] + }); + + const phase1Findings = result.findings.filter(f => f.phase === 1); + expect(phase1Findings.every(f => f.certainty === CERTAINTY.HIGH)).toBe(true); + }); + + it('should tag multi-pass findings as MEDIUM certainty', () => { + const code = ` +/** + * Excessive documentation + * Line 1 + * Line 2 + * Line 3 + * Line 4 + * Line 5 + * Line 6 + * Line 7 + * Line 8 + * Line 9 + * Line 10 + * Line 11 + * Line 12 + */ +function foo() { + return 1; +}`; + fs.writeFileSync(path.join(tmpDir, 'test.js'), code); + + const result = runPipeline(tmpDir, { + thoroughness: 'normal', + targetFiles: ['test.js'] + }); + + const docRatioFindings = result.findings.filter(f => f.patternName === 'doc_code_ratio_js'); + if (docRatioFindings.length > 0) { + expect(docRatioFindings[0].certainty).toBe(CERTAINTY.MEDIUM); + } + }); + }); + + describe('thoroughness levels', () => { + it('quick mode should only run Phase 1 regex', () => { + fs.writeFileSync(path.join(tmpDir, 'a.js'), 'console.log("test");'); + + const result = runPipeline(tmpDir, { + thoroughness: 'quick', + targetFiles: ['a.js'] + }); + + // All findings should be phase 1 with HIGH certainty + expect(result.findings.every(f => f.phase === 1)).toBe(true); + expect(result.findings.every(f => f.certainty === CERTAINTY.HIGH)).toBe(true); + }); + + it('normal mode should include multi-pass analyzers', () => { + // Create file that will trigger multi-pass analysis + const code = ` +/** + * Excessive docs + * Line 1 + * Line 2 + * Line 3 + * Line 4 + * Line 5 + * Line 6 + * Line 7 + * Line 8 + * Line 9 + * Line 10 + */ +function foo() { + return 1; +}`; + fs.writeFileSync(path.join(tmpDir, 'test.js'), code); + + const result = runPipeline(tmpDir, { + thoroughness: 'normal', + targetFiles: ['test.js'] + }); + + // Should run multi-pass analyzers which may produce MEDIUM certainty findings + // The doc_code_ratio analyzer may detect the excessive JSDoc if it meets thresholds + expect(result.findings).toBeDefined(); + expect(result.metadata.thoroughness).toBe('normal'); + }); + + it('deep mode should track missing CLI tools', () => { + const result = runPipeline(tmpDir, { + thoroughness: 'deep', + cliTools: { jscpd: false, madge: false, escomplex: false } + }); + + expect(result.missingTools.length).toBe(3); + }); + }); +}); diff --git a/docs/USAGE.md b/docs/USAGE.md index 7fb2cdf0..590f49af 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -47,7 +47,7 @@ Complete guide to using awesome-slash commands with real-world examples. ### `/deslop-around` -Remove debugging code, old TODOs, and AI slop from your codebase. +Remove debugging code, old TODOs, and AI slop from your codebase with a 3-phase detection pipeline. ```bash /deslop-around # Report mode (safe, no changes) @@ -55,11 +55,30 @@ Remove debugging code, old TODOs, and AI slop from your codebase. /deslop-around apply src/ 10 # Fix 10 issues in src/ only ``` +**Architecture:** +- **Phase 1** - Built-in regex patterns (HIGH certainty) - always runs +- **Phase 2** - Multi-pass analyzers (MEDIUM certainty) - context-aware +- **Phase 3** - Optional CLI tools (LOW certainty) - graceful degradation + - JavaScript/TypeScript: jscpd, madge, escomplex + - Python: pylint, radon + - Go: golangci-lint + - Rust: clippy + +**Thoroughness levels:** +- `quick` - Phase 1 only (fastest) +- `normal` - Phase 1 + Phase 2 (default, recommended) +- `deep` - All phases if tools available (most thorough) + **Detects:** - Console debugging (`console.log`, `print()`, `dbg!()`) - Old TODOs and commented code - Placeholder text, magic numbers - Empty catch blocks, disabled linters +- Placeholder functions +- Excessive documentation +- Phantom references +- Buzzword inflation +- Code smells and over-engineering patterns --- diff --git a/lib/config/index.js b/lib/config/index.js new file mode 100644 index 00000000..25bffeb2 --- /dev/null +++ b/lib/config/index.js @@ -0,0 +1,14 @@ +/** + * Configuration Module + * + * Placeholder for future configuration management. + * This module exists to satisfy lib/index.js imports. + * + * @module config + * @author Avi Fenesh + * @license MIT + */ + +module.exports = { + // Configuration will be added as needed +}; diff --git a/lib/index.js b/lib/index.js index f927426e..860cb976 100644 --- a/lib/index.js +++ b/lib/index.js @@ -14,6 +14,8 @@ const detectPlatform = require('./platform/detect-platform'); const verifyTools = require('./platform/verify-tools'); const reviewPatterns = require('./patterns/review-patterns'); const slopPatterns = require('./patterns/slop-patterns'); +const pipeline = require('./patterns/pipeline'); +const cliEnhancers = require('./patterns/cli-enhancers'); const workflowState = require('./state/workflow-state'); const contextOptimizer = require('./utils/context-optimizer'); const shellEscape = require('./utils/shell-escape'); @@ -65,7 +67,32 @@ const patterns = { * Slop patterns for AI-generated code detection * @see module:patterns/slop-patterns */ - slop: slopPatterns + slop: slopPatterns, + + /** + * Slop detection pipeline orchestrator + * @see module:patterns/pipeline + */ + pipeline: { + runPipeline: pipeline.runPipeline, + CERTAINTY: pipeline.CERTAINTY, + THOROUGHNESS: pipeline.THOROUGHNESS, + formatHandoffPrompt: pipeline.formatHandoffPrompt, + buildSummary: pipeline.buildSummary + }, + + /** + * Optional CLI tool enhancers for deep analysis + * @see module:patterns/cli-enhancers + */ + cliEnhancers: { + detectAvailableTools: cliEnhancers.detectAvailableTools, + runDuplicateDetection: cliEnhancers.runDuplicateDetection, + runDependencyAnalysis: cliEnhancers.runDependencyAnalysis, + runComplexityAnalysis: cliEnhancers.runComplexityAnalysis, + getMissingToolsMessage: cliEnhancers.getMissingToolsMessage, + CLI_TOOLS: cliEnhancers.CLI_TOOLS + } }; /** @@ -161,6 +188,8 @@ module.exports = { verifyTools, reviewPatterns, slopPatterns, + pipeline, + cliEnhancers, workflowState, contextOptimizer, shellEscape, diff --git a/lib/patterns/cli-enhancers.js b/lib/patterns/cli-enhancers.js new file mode 100644 index 00000000..6cfac1da --- /dev/null +++ b/lib/patterns/cli-enhancers.js @@ -0,0 +1,602 @@ +/** + * CLI Enhancers for Slop Detection Pipeline + * + * Optional CLI tool integration for Phase 2 detection. + * All tools are user-installed globally - zero npm dependencies for this module. + * Functions gracefully degrade when tools are not available. + * + * Supported languages: javascript, typescript, python, rust, go + * + * @module patterns/cli-enhancers + * @author Avi Fenesh + * @license MIT + */ + +const { execSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const { escapeDoubleQuotes } = require('../utils/shell-escape'); + +/** + * Cache for tool availability (per-repo) + * Key: repoPath, Value: { tools: {...}, languages: [...], timestamp: Date } + */ +const toolCache = new Map(); +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Supported languages (must match slop-patterns.js) + */ +const SUPPORTED_LANGUAGES = ['javascript', 'typescript', 'python', 'rust', 'go']; + +/** + * CLI tool definitions organized by language + * Only includes tools for supported languages + */ +const CLI_TOOLS = { + // Cross-language tools + jscpd: { + name: 'jscpd', + description: 'Copy/paste detector for code duplication', + checkCommand: 'jscpd --version', + installHint: 'npm install -g jscpd', + languages: ['javascript', 'typescript', 'python', 'go', 'rust'] + }, + + // JavaScript/TypeScript tools + madge: { + name: 'madge', + description: 'Circular dependency detector', + checkCommand: 'madge --version', + installHint: 'npm install -g madge', + languages: ['javascript', 'typescript'] + }, + escomplex: { + name: 'escomplex', + description: 'Cyclomatic complexity analyzer', + checkCommand: 'escomplex --version', + installHint: 'npm install -g escomplex', + languages: ['javascript'] + }, + + // Python tools + pylint: { + name: 'pylint', + description: 'Python linter with complexity analysis', + checkCommand: 'pylint --version', + installHint: 'pip install pylint', + languages: ['python'] + }, + radon: { + name: 'radon', + description: 'Python complexity and maintainability metrics', + checkCommand: 'radon --version', + installHint: 'pip install radon', + languages: ['python'] + }, + + // Go tools + golangci_lint: { + name: 'golangci-lint', + description: 'Go linters aggregator with complexity checks', + checkCommand: 'golangci-lint --version', + installHint: 'go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest', + languages: ['go'] + }, + + // Rust tools + clippy: { + name: 'cargo-clippy', + description: 'Rust linter with code smell detection', + checkCommand: 'cargo clippy --version', + installHint: 'rustup component add clippy', + languages: ['rust'] + } +}; + +/** + * Check if a CLI tool is available in PATH + * + * @param {string} command - Command to check (e.g., 'jscpd --version') + * @returns {boolean} True if tool is available + */ +function isToolAvailable(command) { + try { + execSync(command, { + stdio: 'pipe', + timeout: 5000, + windowsHide: true + }); + return true; + } catch { + return false; + } +} + +/** + * Get cache key for a repo + * @param {string} repoPath - Repository root path + * @returns {string} Cache key + */ +function getCacheKey(repoPath) { + return path.resolve(repoPath); +} + +/** + * Check if cache is valid + * @param {Object} cacheEntry - Cache entry + * @returns {boolean} True if cache is still valid + */ +function isCacheValid(cacheEntry) { + if (!cacheEntry) return false; + return Date.now() - cacheEntry.timestamp < CACHE_TTL_MS; +} + +/** + * Clear the tool cache (useful for testing) + */ +function clearCache() { + toolCache.clear(); +} + +/** + * Detect primary language(s) of a repository based on file extensions and config files + * + * @param {string} repoPath - Repository root path + * @returns {string[]} Array of detected languages (only supported ones) + */ +function detectProjectLanguages(repoPath) { + const languages = new Set(); + + // Check for language-specific config files + const configIndicators = { + 'package.json': ['javascript', 'typescript'], + 'tsconfig.json': ['typescript'], + 'requirements.txt': ['python'], + 'setup.py': ['python'], + 'pyproject.toml': ['python'], + 'Pipfile': ['python'], + 'go.mod': ['go'], + 'go.sum': ['go'], + 'Cargo.toml': ['rust'] + }; + + for (const [file, langs] of Object.entries(configIndicators)) { + if (fs.existsSync(path.join(repoPath, file))) { + langs.forEach(l => languages.add(l)); + } + } + + // If no config files found, scan for source files + if (languages.size === 0) { + const extensionMap = { + '.js': 'javascript', + '.jsx': 'javascript', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.ts': 'typescript', + '.tsx': 'typescript', + '.py': 'python', + '.go': 'go', + '.rs': 'rust' + }; + + // Quick scan of top-level and src/ directories + const dirsToScan = [repoPath, path.join(repoPath, 'src'), path.join(repoPath, 'lib')]; + + for (const dir of dirsToScan) { + if (!fs.existsSync(dir)) continue; + try { + const files = fs.readdirSync(dir); + for (const file of files) { + const ext = path.extname(file).toLowerCase(); + if (extensionMap[ext]) { + languages.add(extensionMap[ext]); + } + } + } catch { + // Directory not readable + } + } + } + + // Filter to only supported languages + const result = Array.from(languages).filter(l => SUPPORTED_LANGUAGES.includes(l)); + + // Default to javascript if nothing detected + if (result.length === 0) { + result.push('javascript'); + } + + return result; +} + +/** + * Get tools relevant for specific languages + * + * @param {string[]} languages - Array of language names + * @returns {Object} Filtered CLI_TOOLS for the specified languages + */ +function getToolsForLanguages(languages) { + const relevant = {}; + + for (const [toolName, tool] of Object.entries(CLI_TOOLS)) { + if (tool.languages.some(lang => languages.includes(lang))) { + relevant[toolName] = tool; + } + } + + return relevant; +} + +/** + * Detect which CLI tools are available on the system + * Uses cache when available + * + * @param {string[]} [languages] - Optional languages to filter tools for + * @param {string} [repoPath] - Optional repo path for caching + * @returns {Object} Object with tool names as keys and availability as boolean values + */ +function detectAvailableTools(languages = null, repoPath = null) { + // Check cache if repoPath provided + if (repoPath) { + const cacheKey = getCacheKey(repoPath); + const cached = toolCache.get(cacheKey); + if (isCacheValid(cached)) { + // Return cached tools filtered by languages if specified + if (languages) { + const relevantTools = getToolsForLanguages(languages); + const filtered = {}; + for (const name of Object.keys(relevantTools)) { + filtered[name] = cached.tools[name] || false; + } + return filtered; + } + return { ...cached.tools }; + } + } + + // Get tools to check + const toolsToCheck = languages ? getToolsForLanguages(languages) : CLI_TOOLS; + const result = {}; + + for (const [toolName, tool] of Object.entries(toolsToCheck)) { + result[toolName] = isToolAvailable(tool.checkCommand); + } + + // Update cache if repoPath provided + if (repoPath) { + const cacheKey = getCacheKey(repoPath); + const existing = toolCache.get(cacheKey) || {}; + toolCache.set(cacheKey, { + tools: { ...existing.tools, ...result }, + languages: languages || existing.languages || [], + timestamp: Date.now() + }); + } + + return result; +} + +/** + * Get tool availability for a specific repo (with caching) + * + * @param {string} repoPath - Repository root path + * @param {Object} [options] - Options + * @param {boolean} [options.forceRefresh=false] - Force cache refresh + * @returns {{ available: Object, missing: string[], languages: string[] }} Tool availability info + */ +function getToolAvailabilityForRepo(repoPath, options = {}) { + const cacheKey = getCacheKey(repoPath); + + // Check cache unless force refresh + if (!options.forceRefresh) { + const cached = toolCache.get(cacheKey); + if (isCacheValid(cached) && cached.languages && cached.languages.length > 0) { + const relevantTools = getToolsForLanguages(cached.languages); + const missing = Object.keys(relevantTools).filter(t => !cached.tools[t]); + return { + available: { ...cached.tools }, + missing, + languages: [...cached.languages] + }; + } + } + + // Detect languages + const languages = detectProjectLanguages(repoPath); + + // Detect tools for those languages + const available = detectAvailableTools(languages, repoPath); + + // Find missing tools + const relevantTools = getToolsForLanguages(languages); + const missing = Object.keys(relevantTools).filter(t => !available[t]); + + // Update cache + toolCache.set(cacheKey, { + tools: available, + languages, + timestamp: Date.now() + }); + + return { available, missing, languages }; +} + +/** + * Run duplicate code detection using jscpd + * + * @param {string} repoPath - Repository root path + * @param {Object} options - Options + * @param {number} [options.minLines=5] - Minimum lines for duplicate detection + * @param {number} [options.minTokens=50] - Minimum tokens for duplicate detection + * @returns {Array|null} Duplicates found, or null if tool not available + */ +function runDuplicateDetection(repoPath, options = {}) { + if (!isToolAvailable(CLI_TOOLS.jscpd.checkCommand)) { + return null; + } + + const minLines = options.minLines || 5; + const minTokens = options.minTokens || 50; + + try { + // Run jscpd with JSON output + // Escape repoPath to prevent command injection + const outputPath = process.platform === 'win32' ? 'NUL' : '/dev/null'; + const safeRepoPath = escapeDoubleQuotes(repoPath); + const command = `jscpd "${safeRepoPath}" --min-lines ${minLines} --min-tokens ${minTokens} --reporters json --output ${outputPath} --silent 2>&1`; + + const result = execSync(command, { + stdio: 'pipe', + timeout: 60000, + windowsHide: true, + cwd: repoPath, + encoding: 'utf8' + }); + + // Parse JSON output + try { + const report = JSON.parse(result); + const duplicates = []; + + if (report.duplicates) { + for (const dup of report.duplicates) { + duplicates.push({ + firstFile: dup.firstFile?.name || 'unknown', + firstLine: dup.firstFile?.start || 0, + secondFile: dup.secondFile?.name || 'unknown', + secondLine: dup.secondFile?.start || 0, + lines: dup.lines || 0, + tokens: dup.tokens || 0, + fragment: dup.fragment?.substring(0, 100) || '' + }); + } + } + + return duplicates; + } catch { + // JSON parsing failed, return empty array + return []; + } + } catch { + // Tool execution failed + return null; + } +} + +/** + * Run circular dependency detection using madge + * + * @param {string} repoPath - Repository root path + * @param {Object} options - Options + * @param {string} [options.entry] - Entry file (defaults to src/index.js or index.js) + * @returns {Array|null} Circular dependency cycles, or null if tool not available + */ +function runDependencyAnalysis(repoPath, options = {}) { + if (!isToolAvailable(CLI_TOOLS.madge.checkCommand)) { + return null; + } + + // Determine entry point + let entry = options.entry; + if (!entry) { + const possibleEntries = [ + 'src/index.js', + 'src/index.ts', + 'index.js', + 'index.ts', + 'lib/index.js', + 'main.js' + ]; + + for (const e of possibleEntries) { + if (fs.existsSync(path.join(repoPath, e))) { + entry = e; + break; + } + } + } + + if (!entry) { + // No entry point found, scan entire directory + entry = '.'; + } + + try { + // Run madge with circular flag and JSON output + // Escape entry path to prevent command injection + const safeEntry = escapeDoubleQuotes(entry); + const command = `madge --circular --json "${safeEntry}"`; + + const result = execSync(command, { + stdio: 'pipe', + timeout: 60000, + windowsHide: true, + cwd: repoPath, + encoding: 'utf8' + }); + + // Parse JSON output + try { + const cycles = JSON.parse(result); + // madge returns array of arrays (each cycle is an array of file paths) + return Array.isArray(cycles) ? cycles : []; + } catch { + return []; + } + } catch { + // Tool execution failed + return null; + } +} + +/** + * Run complexity analysis using escomplex + * + * @param {string} repoPath - Repository root path + * @param {string[]} targetFiles - Files to analyze + * @param {Object} options - Options + * @returns {Array|null} Complexity results, or null if tool not available + */ +function runComplexityAnalysis(repoPath, targetFiles, options = {}) { + if (!isToolAvailable(CLI_TOOLS.escomplex.checkCommand)) { + return null; + } + + const results = []; + + // escomplex works on individual files + for (const file of targetFiles) { + // Only analyze JS/TS files + if (!file.match(/\.[jt]sx?$/)) continue; + + const filePath = path.isAbsolute(file) ? file : path.join(repoPath, file); + + try { + // Escape file path to prevent command injection + const safeFilePath = escapeDoubleQuotes(filePath); + const command = `escomplex "${safeFilePath}" --format json`; + + const result = execSync(command, { + stdio: 'pipe', + timeout: 30000, + windowsHide: true, + cwd: repoPath, + encoding: 'utf8' + }); + + try { + const report = JSON.parse(result); + + // Extract function-level complexity + if (report.functions) { + for (const fn of report.functions) { + results.push({ + file, + name: fn.name || 'anonymous', + line: fn.line || 0, + complexity: fn.cyclomatic || 0, + halstead: fn.halstead?.difficulty || 0, + sloc: fn.sloc?.logical || 0 + }); + } + } + + // Also include module-level metrics + if (report.aggregate) { + results.push({ + file, + name: 'module', + line: 0, + complexity: report.aggregate.cyclomatic || 0, + halstead: report.aggregate.halstead?.difficulty || 0, + sloc: report.aggregate.sloc?.logical || 0, + maintainability: report.maintainability || 0 + }); + } + } catch { + // JSON parsing failed for this file + } + } catch { + // Tool execution failed for this file + } + } + + return results.length > 0 ? results : null; +} + +/** + * Get user-friendly message about missing tools (language-aware) + * + * @param {string[]} missingTools - Array of missing tool names + * @param {string[]} [languages] - Detected languages (for context in message) + * @returns {string} Formatted message + */ +function getMissingToolsMessage(missingTools, languages = null) { + if (!missingTools || missingTools.length === 0) { + return ''; + } + + // Filter to only known tools + const validTools = missingTools.filter(t => CLI_TOOLS[t]); + if (validTools.length === 0) { + return ''; + } + + let message = '\n## Enhanced Analysis Available\n\n'; + + if (languages && languages.length > 0) { + message += `Detected project languages: ${languages.join(', ')}\n\n`; + } + + message += 'For deeper analysis, consider installing:\n\n'; + + for (const toolName of validTools) { + const tool = CLI_TOOLS[toolName]; + if (tool) { + message += `- **${tool.name}**: ${tool.description}\n`; + message += ` Install: \`${tool.installHint}\`\n`; + } + } + + message += '\nThese tools are optional and enhance detection capabilities.\n'; + + return message; +} + +/** + * Get all CLI tool definitions + * + * @returns {Object} CLI tool definitions + */ +function getToolDefinitions() { + return { ...CLI_TOOLS }; +} + +/** + * Get supported languages list + * + * @returns {string[]} Array of supported language names + */ +function getSupportedLanguages() { + return [...SUPPORTED_LANGUAGES]; +} + +module.exports = { + detectAvailableTools, + detectProjectLanguages, + getToolsForLanguages, + getToolAvailabilityForRepo, + runDuplicateDetection, + runDependencyAnalysis, + runComplexityAnalysis, + getMissingToolsMessage, + getToolDefinitions, + getSupportedLanguages, + clearCache, + // Exported for testing + isToolAvailable, + CLI_TOOLS, + SUPPORTED_LANGUAGES +}; diff --git a/lib/patterns/pipeline.js b/lib/patterns/pipeline.js new file mode 100644 index 00000000..788d759a --- /dev/null +++ b/lib/patterns/pipeline.js @@ -0,0 +1,565 @@ +/** + * Slop Detection Pipeline + * + * 3-phase detection pipeline orchestrator: + * - Phase 1 (built-in): regex patterns + multi-pass analyzers - always runs + * - Phase 2 (optional): CLI tools (jscpd, madge, escomplex) - if available + * - Phase 3 (LLM handoff): certainty-tagged findings for agent review + * + * Inherits modes from deslop-around: report (analyze only) vs apply (fix issues) + * + * @module patterns/pipeline + * @author Avi Fenesh + * @license MIT + */ + +const path = require('path'); +const fs = require('fs'); +const slopPatterns = require('./slop-patterns'); +const analyzers = require('./slop-analyzers'); + +/** + * Certainty levels for findings + * HIGH: Single regex match - definitive + * MEDIUM: Multi-pass analysis - requires context + * LOW: Heuristic/CLI tool - needs verification + */ +const CERTAINTY = { + HIGH: 'HIGH', + MEDIUM: 'MEDIUM', + LOW: 'LOW' +}; + +/** + * Thoroughness levels + * quick: Phase 1 regex only - fastest + * normal: Phase 1 + multi-pass analyzers - balanced + * deep: Phase 1 + Phase 2 CLI tools (if available) - thorough + */ +const THOROUGHNESS = { + QUICK: 'quick', + NORMAL: 'normal', + DEEP: 'deep' +}; + +/** + * Run the slop detection pipeline + * + * @param {string} repoPath - Repository root path + * @param {Object} options - Pipeline options + * @param {string} [options.thoroughness='normal'] - quick | normal | deep + * @param {string[]} [options.targetFiles] - Specific files to analyze (defaults to all source files) + * @param {string} [options.language] - Filter to specific language + * @param {string} [options.mode='report'] - report | apply + * @param {Object} [options.cliTools] - Pre-detected CLI tools (from detectAvailableTools) + * @returns {Object} Pipeline results: { findings, summary, phase3Prompt, missingTools } + */ +function runPipeline(repoPath, options = {}) { + const thoroughness = options.thoroughness || THOROUGHNESS.NORMAL; + const mode = options.mode || 'report'; + const language = options.language || null; + + const findings = []; + const missingTools = []; + let cliTools = options.cliTools || null; + + // Get target files + let targetFiles = options.targetFiles; + if (!targetFiles || targetFiles.length === 0) { + const result = analyzers.countSourceFiles(repoPath, { + maxFiles: 1000, + includeTests: false + }); + targetFiles = result.files; + } + + // Phase 1: Built-in regex patterns (always runs) + const phase1Results = runPhase1(repoPath, targetFiles, language); + findings.push(...phase1Results); + + // Phase 1b: Multi-pass analyzers (if normal or deep) + if (thoroughness !== THOROUGHNESS.QUICK) { + const multiPassResults = runMultiPassAnalyzers(repoPath, targetFiles); + findings.push(...multiPassResults); + } + + // Phase 2: CLI tools (only if deep and tools available) + // Detect project languages for language-aware tool recommendations + let detectedLanguages = []; + if (thoroughness === THOROUGHNESS.DEEP) { + // Lazy-load CLI enhancers to avoid circular dependencies + const cliEnhancers = require('./cli-enhancers'); + + // Detect project languages + detectedLanguages = cliEnhancers.detectProjectLanguages(repoPath); + + if (!cliTools) { + // Get tools relevant for detected languages + cliTools = cliEnhancers.detectAvailableTools(detectedLanguages); + } + + // Track missing tools (only those relevant for project languages) + const relevantTools = cliEnhancers.getToolsForLanguages(detectedLanguages); + for (const toolName of Object.keys(relevantTools)) { + if (!cliTools[toolName]) { + missingTools.push(toolName); + } + } + + const phase2Results = runPhase2(repoPath, cliTools, targetFiles); + findings.push(...phase2Results); + } + + // Build summary + const summary = buildSummary(findings); + + // Generate Phase 3 handoff prompt + const phase3Prompt = formatHandoffPrompt(findings, mode); + + return { + findings, + summary, + phase3Prompt, + missingTools, + detectedLanguages, + metadata: { + repoPath, + thoroughness, + mode, + filesAnalyzed: targetFiles.length, + timestamp: new Date().toISOString() + } + }; +} + +/** + * Phase 1: Run built-in regex patterns against target files + * + * @param {string} repoPath - Repository root + * @param {string[]} targetFiles - Files to analyze + * @param {string|null} language - Optional language filter + * @returns {Array} Findings with HIGH certainty + */ +function runPhase1(repoPath, targetFiles, language) { + const findings = []; + + // Get patterns (filtered by language if specified) + const patterns = language + ? slopPatterns.getPatternsForLanguage(language) + : slopPatterns.slopPatterns; + + for (const file of targetFiles) { + // Skip if language filter doesn't match file extension + if (language) { + const fileLanguage = analyzers.detectLanguage(file); + // For JS/TS language filter, accept both 'javascript' and 'js' detection results + const isJsFamily = (language === 'javascript' || language === 'typescript') && fileLanguage === 'js'; + if (fileLanguage !== language && !isJsFamily) continue; + } + + const filePath = path.isAbsolute(file) ? file : path.join(repoPath, file); + + let content; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch { + continue; // Skip unreadable files + } + + const lines = content.split('\n'); + + for (const [patternName, pattern] of Object.entries(patterns)) { + // Skip multi-pass patterns (handled separately) + if (pattern.requiresMultiPass) continue; + + // Skip if no regex pattern + if (!pattern.pattern) continue; + + // Skip if file matches exclude patterns + if (slopPatterns.isFileExcluded(file, pattern.exclude)) continue; + + // Check each line + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (pattern.pattern.test(line)) { + findings.push({ + file, + line: i + 1, + patternName, + severity: pattern.severity, + certainty: CERTAINTY.HIGH, + description: pattern.description, + autoFix: pattern.autoFix, + content: line.trim().substring(0, 100), + phase: 1 + }); + } + } + } + } + + return findings; +} + +/** + * Run multi-pass analyzers (doc/code ratio, verbosity, etc.) + * + * @param {string} repoPath - Repository root + * @param {string[]} targetFiles - Files to analyze + * @returns {Array} Findings with MEDIUM certainty + */ +function runMultiPassAnalyzers(repoPath, targetFiles) { + const findings = []; + + // Get multi-pass pattern definitions for thresholds + const multiPassPatterns = slopPatterns.getMultiPassPatterns(); + + for (const file of targetFiles) { + const filePath = path.isAbsolute(file) ? file : path.join(repoPath, file); + const lang = analyzers.detectLanguage(file); + + // Skip non-JS files for doc/code ratio (JSDoc specific) + if (lang !== 'js') continue; + + let content; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch { + continue; + } + + // Doc/code ratio analysis + const docCodePattern = multiPassPatterns.doc_code_ratio_js; + if (docCodePattern) { + const docRatioViolations = analyzers.analyzeDocCodeRatio(content, { + minFunctionLines: docCodePattern.minFunctionLines || 3, + maxRatio: docCodePattern.maxRatio || 3.0 + }); + + for (const v of docRatioViolations) { + findings.push({ + file, + line: v.line, + patternName: 'doc_code_ratio_js', + severity: docCodePattern.severity, + certainty: CERTAINTY.MEDIUM, + description: `${docCodePattern.description} (${v.docLines} doc lines / ${v.codeLines} code lines = ${v.ratio}x)`, + autoFix: docCodePattern.autoFix, + content: `Function at line ${v.line}`, + phase: 1, + details: { docLines: v.docLines, codeLines: v.codeLines, ratio: v.ratio } + }); + } + } + + // Verbosity ratio analysis + const verbosityPattern = multiPassPatterns.verbosity_ratio; + if (verbosityPattern) { + const verbosityViolations = analyzers.analyzeVerbosityRatio(content, { + minCodeLines: verbosityPattern.minCodeLines || 3, + maxCommentRatio: verbosityPattern.maxCommentRatio || 2.0, + filePath: file + }); + + for (const v of verbosityViolations) { + findings.push({ + file, + line: v.line, + patternName: 'verbosity_ratio', + severity: verbosityPattern.severity, + certainty: CERTAINTY.MEDIUM, + description: `${verbosityPattern.description} (${v.commentLines} comment lines / ${v.codeLines} code lines = ${v.ratio}x)`, + autoFix: verbosityPattern.autoFix, + content: `Function at line ${v.line}`, + phase: 1, + details: { commentLines: v.commentLines, codeLines: v.codeLines, ratio: v.ratio } + }); + } + } + } + + // Project-level analyzers (run once, not per-file) + const overEngPattern = multiPassPatterns.over_engineering_metrics; + if (overEngPattern) { + const overEngResult = analyzers.analyzeOverEngineering(repoPath, { + fileRatioThreshold: overEngPattern.fileRatioThreshold || 20, + linesPerExportThreshold: overEngPattern.linesPerExportThreshold || 500, + depthThreshold: overEngPattern.depthThreshold || 4 + }); + + for (const v of overEngResult.violations) { + findings.push({ + file: 'project-level', + line: 0, + patternName: 'over_engineering_metrics', + severity: v.severity, + certainty: CERTAINTY.MEDIUM, + description: `Over-engineering: ${v.type} - ${v.value} (threshold: ${v.threshold})`, + autoFix: 'flag', + content: v.value, + phase: 1, + details: v.details + }); + } + } + + // Buzzword inflation analysis + const buzzwordPattern = multiPassPatterns.buzzword_inflation; + if (buzzwordPattern) { + const buzzwordResult = analyzers.analyzeBuzzwordInflation(repoPath, { + minEvidenceMatches: buzzwordPattern.minEvidenceMatches || 2 + }); + + for (const v of buzzwordResult.violations) { + findings.push({ + file: v.file, + line: v.line, + patternName: 'buzzword_inflation', + severity: v.severity, + certainty: CERTAINTY.MEDIUM, + description: v.message, + autoFix: 'flag', + content: v.claim, + phase: 1, + details: { buzzword: v.buzzword, category: v.category, evidenceCount: v.evidenceCount } + }); + } + } + + // Infrastructure without implementation + const infraPattern = multiPassPatterns.infrastructure_without_implementation; + if (infraPattern) { + const infraResult = analyzers.analyzeInfrastructureWithoutImplementation(repoPath); + + for (const v of infraResult.violations) { + findings.push({ + file: v.file, + line: v.line, + patternName: 'infrastructure_without_implementation', + severity: v.severity, + certainty: CERTAINTY.MEDIUM, + description: v.message, + autoFix: 'flag', + content: v.content, + phase: 1, + details: { varName: v.varName, type: v.type } + }); + } + } + + return findings; +} + +/** + * Phase 2: Run CLI tools (if available) + * + * @param {string} repoPath - Repository root + * @param {Object} cliTools - Available CLI tools { jscpd, madge, escomplex } + * @param {string[]} targetFiles - Files to analyze + * @returns {Array} Findings with LOW certainty + */ +function runPhase2(repoPath, cliTools, targetFiles) { + const findings = []; + const cliEnhancers = require('./cli-enhancers'); + + // Duplicate detection with jscpd + if (cliTools.jscpd) { + const duplicates = cliEnhancers.runDuplicateDetection(repoPath); + if (duplicates) { + for (const dup of duplicates) { + findings.push({ + file: dup.firstFile, + line: dup.firstLine, + patternName: 'code_duplication', + severity: 'medium', + certainty: CERTAINTY.LOW, + description: `Code duplication: ${dup.lines} lines duplicated in ${dup.secondFile}:${dup.secondLine}`, + autoFix: 'flag', + content: `${dup.lines} lines duplicated`, + phase: 2, + details: dup + }); + } + } + } + + // Circular dependencies with madge + if (cliTools.madge) { + const circularDeps = cliEnhancers.runDependencyAnalysis(repoPath); + if (circularDeps) { + for (const cycle of circularDeps) { + findings.push({ + file: cycle[0], + line: 0, + patternName: 'circular_dependency', + severity: 'high', + certainty: CERTAINTY.LOW, + description: `Circular dependency: ${cycle.join(' -> ')}`, + autoFix: 'flag', + content: cycle.join(' -> '), + phase: 2, + details: { cycle } + }); + } + } + } + + // Complexity analysis with escomplex + if (cliTools.escomplex) { + const complexityResults = cliEnhancers.runComplexityAnalysis(repoPath, targetFiles); + if (complexityResults) { + for (const result of complexityResults) { + if (result.complexity > 10) { // High cyclomatic complexity threshold + findings.push({ + file: result.file, + line: result.line || 0, + patternName: 'high_complexity', + severity: result.complexity > 20 ? 'high' : 'medium', + certainty: CERTAINTY.LOW, + description: `High cyclomatic complexity: ${result.complexity} in ${result.name}`, + autoFix: 'flag', + content: `${result.name}: complexity ${result.complexity}`, + phase: 2, + details: result + }); + } + } + } + } + + return findings; +} + +/** + * Build summary statistics from findings + * + * @param {Array} findings - All findings + * @returns {Object} Summary statistics + */ +function buildSummary(findings) { + const summary = { + total: findings.length, + bySeverity: { critical: 0, high: 0, medium: 0, low: 0 }, + byCertainty: { HIGH: 0, MEDIUM: 0, LOW: 0 }, + byPhase: { 1: 0, 2: 0 }, + byAutoFix: { remove: 0, replace: 0, add_logging: 0, flag: 0, none: 0 }, + topPatterns: {} + }; + + for (const f of findings) { + summary.bySeverity[f.severity] = (summary.bySeverity[f.severity] || 0) + 1; + summary.byCertainty[f.certainty] = (summary.byCertainty[f.certainty] || 0) + 1; + summary.byPhase[f.phase] = (summary.byPhase[f.phase] || 0) + 1; + summary.byAutoFix[f.autoFix] = (summary.byAutoFix[f.autoFix] || 0) + 1; + summary.topPatterns[f.patternName] = (summary.topPatterns[f.patternName] || 0) + 1; + } + + return summary; +} + +/** + * Format handoff prompt for LLM (Phase 3) + * + * Creates a token-efficient prompt for the agent to review findings. + * Groups by certainty level with action guidance: + * - HIGH: Apply directly (if apply mode) + * - MEDIUM: Verify context before applying + * - LOW: Use judgment, may be false positive + * + * @param {Array} findings - All findings + * @param {string} mode - report | apply + * @returns {string} Formatted prompt + */ +function formatHandoffPrompt(findings, mode) { + if (findings.length === 0) { + return '## Slop Detection Results\n\nNo issues detected.'; + } + + // Group findings by certainty + const byGroup = { + HIGH: findings.filter(f => f.certainty === CERTAINTY.HIGH), + MEDIUM: findings.filter(f => f.certainty === CERTAINTY.MEDIUM), + LOW: findings.filter(f => f.certainty === CERTAINTY.LOW) + }; + + let prompt = '## Slop Detection Results\n\n'; + prompt += `Mode: **${mode}** | Total: ${findings.length} findings\n\n`; + + // HIGH certainty - definitive matches + if (byGroup.HIGH.length > 0) { + prompt += '### HIGH Certainty (Definitive - trust these)\n\n'; + if (mode === 'apply') { + prompt += '_Action: Apply fixes directly for autoFix patterns._\n\n'; + } + prompt += formatFindingsList(byGroup.HIGH); + prompt += '\n'; + } + + // MEDIUM certainty - needs context verification + if (byGroup.MEDIUM.length > 0) { + prompt += '### MEDIUM Certainty (Verify context)\n\n'; + prompt += '_Action: Review surrounding code before applying._\n\n'; + prompt += formatFindingsList(byGroup.MEDIUM); + prompt += '\n'; + } + + // LOW certainty - use judgment + if (byGroup.LOW.length > 0) { + prompt += '### LOW Certainty (Use judgment)\n\n'; + prompt += '_Action: May be false positives. Investigate before acting._\n\n'; + prompt += formatFindingsList(byGroup.LOW); + prompt += '\n'; + } + + // Action summary + prompt += '### Action Summary\n\n'; + const autoFixable = findings.filter(f => f.autoFix && f.autoFix !== 'flag' && f.autoFix !== 'none'); + const needsReview = findings.filter(f => f.autoFix === 'flag' || f.autoFix === 'none'); + + prompt += `- Auto-fixable: ${autoFixable.length}\n`; + prompt += `- Needs manual review: ${needsReview.length}\n`; + + return prompt; +} + +/** + * Format a list of findings for the prompt + * + * @param {Array} findings - Findings to format + * @returns {string} Formatted list + */ +function formatFindingsList(findings) { + // Group by file for compact output + const byFile = {}; + for (const f of findings) { + if (!byFile[f.file]) byFile[f.file] = []; + byFile[f.file].push(f); + } + + let output = ''; + for (const [file, fileFindings] of Object.entries(byFile)) { + output += `**${file}**\n`; + for (const f of fileFindings) { + const fixTag = f.autoFix && f.autoFix !== 'flag' && f.autoFix !== 'none' + ? ` [${f.autoFix}]` + : ''; + output += `- L${f.line}: ${f.description}${fixTag}\n`; + } + output += '\n'; + } + + return output; +} + +module.exports = { + runPipeline, + // Exported for testing + runPhase1, + runMultiPassAnalyzers, + runPhase2, + buildSummary, + formatHandoffPrompt, + // Constants + CERTAINTY, + THOROUGHNESS +}; diff --git a/plugins/deslop-around/lib/config/index.js b/plugins/deslop-around/lib/config/index.js new file mode 100644 index 00000000..25bffeb2 --- /dev/null +++ b/plugins/deslop-around/lib/config/index.js @@ -0,0 +1,14 @@ +/** + * Configuration Module + * + * Placeholder for future configuration management. + * This module exists to satisfy lib/index.js imports. + * + * @module config + * @author Avi Fenesh + * @license MIT + */ + +module.exports = { + // Configuration will be added as needed +}; diff --git a/plugins/deslop-around/lib/index.js b/plugins/deslop-around/lib/index.js index f927426e..860cb976 100644 --- a/plugins/deslop-around/lib/index.js +++ b/plugins/deslop-around/lib/index.js @@ -14,6 +14,8 @@ const detectPlatform = require('./platform/detect-platform'); const verifyTools = require('./platform/verify-tools'); const reviewPatterns = require('./patterns/review-patterns'); const slopPatterns = require('./patterns/slop-patterns'); +const pipeline = require('./patterns/pipeline'); +const cliEnhancers = require('./patterns/cli-enhancers'); const workflowState = require('./state/workflow-state'); const contextOptimizer = require('./utils/context-optimizer'); const shellEscape = require('./utils/shell-escape'); @@ -65,7 +67,32 @@ const patterns = { * Slop patterns for AI-generated code detection * @see module:patterns/slop-patterns */ - slop: slopPatterns + slop: slopPatterns, + + /** + * Slop detection pipeline orchestrator + * @see module:patterns/pipeline + */ + pipeline: { + runPipeline: pipeline.runPipeline, + CERTAINTY: pipeline.CERTAINTY, + THOROUGHNESS: pipeline.THOROUGHNESS, + formatHandoffPrompt: pipeline.formatHandoffPrompt, + buildSummary: pipeline.buildSummary + }, + + /** + * Optional CLI tool enhancers for deep analysis + * @see module:patterns/cli-enhancers + */ + cliEnhancers: { + detectAvailableTools: cliEnhancers.detectAvailableTools, + runDuplicateDetection: cliEnhancers.runDuplicateDetection, + runDependencyAnalysis: cliEnhancers.runDependencyAnalysis, + runComplexityAnalysis: cliEnhancers.runComplexityAnalysis, + getMissingToolsMessage: cliEnhancers.getMissingToolsMessage, + CLI_TOOLS: cliEnhancers.CLI_TOOLS + } }; /** @@ -161,6 +188,8 @@ module.exports = { verifyTools, reviewPatterns, slopPatterns, + pipeline, + cliEnhancers, workflowState, contextOptimizer, shellEscape, diff --git a/plugins/deslop-around/lib/patterns/cli-enhancers.js b/plugins/deslop-around/lib/patterns/cli-enhancers.js new file mode 100644 index 00000000..6cfac1da --- /dev/null +++ b/plugins/deslop-around/lib/patterns/cli-enhancers.js @@ -0,0 +1,602 @@ +/** + * CLI Enhancers for Slop Detection Pipeline + * + * Optional CLI tool integration for Phase 2 detection. + * All tools are user-installed globally - zero npm dependencies for this module. + * Functions gracefully degrade when tools are not available. + * + * Supported languages: javascript, typescript, python, rust, go + * + * @module patterns/cli-enhancers + * @author Avi Fenesh + * @license MIT + */ + +const { execSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const { escapeDoubleQuotes } = require('../utils/shell-escape'); + +/** + * Cache for tool availability (per-repo) + * Key: repoPath, Value: { tools: {...}, languages: [...], timestamp: Date } + */ +const toolCache = new Map(); +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Supported languages (must match slop-patterns.js) + */ +const SUPPORTED_LANGUAGES = ['javascript', 'typescript', 'python', 'rust', 'go']; + +/** + * CLI tool definitions organized by language + * Only includes tools for supported languages + */ +const CLI_TOOLS = { + // Cross-language tools + jscpd: { + name: 'jscpd', + description: 'Copy/paste detector for code duplication', + checkCommand: 'jscpd --version', + installHint: 'npm install -g jscpd', + languages: ['javascript', 'typescript', 'python', 'go', 'rust'] + }, + + // JavaScript/TypeScript tools + madge: { + name: 'madge', + description: 'Circular dependency detector', + checkCommand: 'madge --version', + installHint: 'npm install -g madge', + languages: ['javascript', 'typescript'] + }, + escomplex: { + name: 'escomplex', + description: 'Cyclomatic complexity analyzer', + checkCommand: 'escomplex --version', + installHint: 'npm install -g escomplex', + languages: ['javascript'] + }, + + // Python tools + pylint: { + name: 'pylint', + description: 'Python linter with complexity analysis', + checkCommand: 'pylint --version', + installHint: 'pip install pylint', + languages: ['python'] + }, + radon: { + name: 'radon', + description: 'Python complexity and maintainability metrics', + checkCommand: 'radon --version', + installHint: 'pip install radon', + languages: ['python'] + }, + + // Go tools + golangci_lint: { + name: 'golangci-lint', + description: 'Go linters aggregator with complexity checks', + checkCommand: 'golangci-lint --version', + installHint: 'go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest', + languages: ['go'] + }, + + // Rust tools + clippy: { + name: 'cargo-clippy', + description: 'Rust linter with code smell detection', + checkCommand: 'cargo clippy --version', + installHint: 'rustup component add clippy', + languages: ['rust'] + } +}; + +/** + * Check if a CLI tool is available in PATH + * + * @param {string} command - Command to check (e.g., 'jscpd --version') + * @returns {boolean} True if tool is available + */ +function isToolAvailable(command) { + try { + execSync(command, { + stdio: 'pipe', + timeout: 5000, + windowsHide: true + }); + return true; + } catch { + return false; + } +} + +/** + * Get cache key for a repo + * @param {string} repoPath - Repository root path + * @returns {string} Cache key + */ +function getCacheKey(repoPath) { + return path.resolve(repoPath); +} + +/** + * Check if cache is valid + * @param {Object} cacheEntry - Cache entry + * @returns {boolean} True if cache is still valid + */ +function isCacheValid(cacheEntry) { + if (!cacheEntry) return false; + return Date.now() - cacheEntry.timestamp < CACHE_TTL_MS; +} + +/** + * Clear the tool cache (useful for testing) + */ +function clearCache() { + toolCache.clear(); +} + +/** + * Detect primary language(s) of a repository based on file extensions and config files + * + * @param {string} repoPath - Repository root path + * @returns {string[]} Array of detected languages (only supported ones) + */ +function detectProjectLanguages(repoPath) { + const languages = new Set(); + + // Check for language-specific config files + const configIndicators = { + 'package.json': ['javascript', 'typescript'], + 'tsconfig.json': ['typescript'], + 'requirements.txt': ['python'], + 'setup.py': ['python'], + 'pyproject.toml': ['python'], + 'Pipfile': ['python'], + 'go.mod': ['go'], + 'go.sum': ['go'], + 'Cargo.toml': ['rust'] + }; + + for (const [file, langs] of Object.entries(configIndicators)) { + if (fs.existsSync(path.join(repoPath, file))) { + langs.forEach(l => languages.add(l)); + } + } + + // If no config files found, scan for source files + if (languages.size === 0) { + const extensionMap = { + '.js': 'javascript', + '.jsx': 'javascript', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.ts': 'typescript', + '.tsx': 'typescript', + '.py': 'python', + '.go': 'go', + '.rs': 'rust' + }; + + // Quick scan of top-level and src/ directories + const dirsToScan = [repoPath, path.join(repoPath, 'src'), path.join(repoPath, 'lib')]; + + for (const dir of dirsToScan) { + if (!fs.existsSync(dir)) continue; + try { + const files = fs.readdirSync(dir); + for (const file of files) { + const ext = path.extname(file).toLowerCase(); + if (extensionMap[ext]) { + languages.add(extensionMap[ext]); + } + } + } catch { + // Directory not readable + } + } + } + + // Filter to only supported languages + const result = Array.from(languages).filter(l => SUPPORTED_LANGUAGES.includes(l)); + + // Default to javascript if nothing detected + if (result.length === 0) { + result.push('javascript'); + } + + return result; +} + +/** + * Get tools relevant for specific languages + * + * @param {string[]} languages - Array of language names + * @returns {Object} Filtered CLI_TOOLS for the specified languages + */ +function getToolsForLanguages(languages) { + const relevant = {}; + + for (const [toolName, tool] of Object.entries(CLI_TOOLS)) { + if (tool.languages.some(lang => languages.includes(lang))) { + relevant[toolName] = tool; + } + } + + return relevant; +} + +/** + * Detect which CLI tools are available on the system + * Uses cache when available + * + * @param {string[]} [languages] - Optional languages to filter tools for + * @param {string} [repoPath] - Optional repo path for caching + * @returns {Object} Object with tool names as keys and availability as boolean values + */ +function detectAvailableTools(languages = null, repoPath = null) { + // Check cache if repoPath provided + if (repoPath) { + const cacheKey = getCacheKey(repoPath); + const cached = toolCache.get(cacheKey); + if (isCacheValid(cached)) { + // Return cached tools filtered by languages if specified + if (languages) { + const relevantTools = getToolsForLanguages(languages); + const filtered = {}; + for (const name of Object.keys(relevantTools)) { + filtered[name] = cached.tools[name] || false; + } + return filtered; + } + return { ...cached.tools }; + } + } + + // Get tools to check + const toolsToCheck = languages ? getToolsForLanguages(languages) : CLI_TOOLS; + const result = {}; + + for (const [toolName, tool] of Object.entries(toolsToCheck)) { + result[toolName] = isToolAvailable(tool.checkCommand); + } + + // Update cache if repoPath provided + if (repoPath) { + const cacheKey = getCacheKey(repoPath); + const existing = toolCache.get(cacheKey) || {}; + toolCache.set(cacheKey, { + tools: { ...existing.tools, ...result }, + languages: languages || existing.languages || [], + timestamp: Date.now() + }); + } + + return result; +} + +/** + * Get tool availability for a specific repo (with caching) + * + * @param {string} repoPath - Repository root path + * @param {Object} [options] - Options + * @param {boolean} [options.forceRefresh=false] - Force cache refresh + * @returns {{ available: Object, missing: string[], languages: string[] }} Tool availability info + */ +function getToolAvailabilityForRepo(repoPath, options = {}) { + const cacheKey = getCacheKey(repoPath); + + // Check cache unless force refresh + if (!options.forceRefresh) { + const cached = toolCache.get(cacheKey); + if (isCacheValid(cached) && cached.languages && cached.languages.length > 0) { + const relevantTools = getToolsForLanguages(cached.languages); + const missing = Object.keys(relevantTools).filter(t => !cached.tools[t]); + return { + available: { ...cached.tools }, + missing, + languages: [...cached.languages] + }; + } + } + + // Detect languages + const languages = detectProjectLanguages(repoPath); + + // Detect tools for those languages + const available = detectAvailableTools(languages, repoPath); + + // Find missing tools + const relevantTools = getToolsForLanguages(languages); + const missing = Object.keys(relevantTools).filter(t => !available[t]); + + // Update cache + toolCache.set(cacheKey, { + tools: available, + languages, + timestamp: Date.now() + }); + + return { available, missing, languages }; +} + +/** + * Run duplicate code detection using jscpd + * + * @param {string} repoPath - Repository root path + * @param {Object} options - Options + * @param {number} [options.minLines=5] - Minimum lines for duplicate detection + * @param {number} [options.minTokens=50] - Minimum tokens for duplicate detection + * @returns {Array|null} Duplicates found, or null if tool not available + */ +function runDuplicateDetection(repoPath, options = {}) { + if (!isToolAvailable(CLI_TOOLS.jscpd.checkCommand)) { + return null; + } + + const minLines = options.minLines || 5; + const minTokens = options.minTokens || 50; + + try { + // Run jscpd with JSON output + // Escape repoPath to prevent command injection + const outputPath = process.platform === 'win32' ? 'NUL' : '/dev/null'; + const safeRepoPath = escapeDoubleQuotes(repoPath); + const command = `jscpd "${safeRepoPath}" --min-lines ${minLines} --min-tokens ${minTokens} --reporters json --output ${outputPath} --silent 2>&1`; + + const result = execSync(command, { + stdio: 'pipe', + timeout: 60000, + windowsHide: true, + cwd: repoPath, + encoding: 'utf8' + }); + + // Parse JSON output + try { + const report = JSON.parse(result); + const duplicates = []; + + if (report.duplicates) { + for (const dup of report.duplicates) { + duplicates.push({ + firstFile: dup.firstFile?.name || 'unknown', + firstLine: dup.firstFile?.start || 0, + secondFile: dup.secondFile?.name || 'unknown', + secondLine: dup.secondFile?.start || 0, + lines: dup.lines || 0, + tokens: dup.tokens || 0, + fragment: dup.fragment?.substring(0, 100) || '' + }); + } + } + + return duplicates; + } catch { + // JSON parsing failed, return empty array + return []; + } + } catch { + // Tool execution failed + return null; + } +} + +/** + * Run circular dependency detection using madge + * + * @param {string} repoPath - Repository root path + * @param {Object} options - Options + * @param {string} [options.entry] - Entry file (defaults to src/index.js or index.js) + * @returns {Array|null} Circular dependency cycles, or null if tool not available + */ +function runDependencyAnalysis(repoPath, options = {}) { + if (!isToolAvailable(CLI_TOOLS.madge.checkCommand)) { + return null; + } + + // Determine entry point + let entry = options.entry; + if (!entry) { + const possibleEntries = [ + 'src/index.js', + 'src/index.ts', + 'index.js', + 'index.ts', + 'lib/index.js', + 'main.js' + ]; + + for (const e of possibleEntries) { + if (fs.existsSync(path.join(repoPath, e))) { + entry = e; + break; + } + } + } + + if (!entry) { + // No entry point found, scan entire directory + entry = '.'; + } + + try { + // Run madge with circular flag and JSON output + // Escape entry path to prevent command injection + const safeEntry = escapeDoubleQuotes(entry); + const command = `madge --circular --json "${safeEntry}"`; + + const result = execSync(command, { + stdio: 'pipe', + timeout: 60000, + windowsHide: true, + cwd: repoPath, + encoding: 'utf8' + }); + + // Parse JSON output + try { + const cycles = JSON.parse(result); + // madge returns array of arrays (each cycle is an array of file paths) + return Array.isArray(cycles) ? cycles : []; + } catch { + return []; + } + } catch { + // Tool execution failed + return null; + } +} + +/** + * Run complexity analysis using escomplex + * + * @param {string} repoPath - Repository root path + * @param {string[]} targetFiles - Files to analyze + * @param {Object} options - Options + * @returns {Array|null} Complexity results, or null if tool not available + */ +function runComplexityAnalysis(repoPath, targetFiles, options = {}) { + if (!isToolAvailable(CLI_TOOLS.escomplex.checkCommand)) { + return null; + } + + const results = []; + + // escomplex works on individual files + for (const file of targetFiles) { + // Only analyze JS/TS files + if (!file.match(/\.[jt]sx?$/)) continue; + + const filePath = path.isAbsolute(file) ? file : path.join(repoPath, file); + + try { + // Escape file path to prevent command injection + const safeFilePath = escapeDoubleQuotes(filePath); + const command = `escomplex "${safeFilePath}" --format json`; + + const result = execSync(command, { + stdio: 'pipe', + timeout: 30000, + windowsHide: true, + cwd: repoPath, + encoding: 'utf8' + }); + + try { + const report = JSON.parse(result); + + // Extract function-level complexity + if (report.functions) { + for (const fn of report.functions) { + results.push({ + file, + name: fn.name || 'anonymous', + line: fn.line || 0, + complexity: fn.cyclomatic || 0, + halstead: fn.halstead?.difficulty || 0, + sloc: fn.sloc?.logical || 0 + }); + } + } + + // Also include module-level metrics + if (report.aggregate) { + results.push({ + file, + name: 'module', + line: 0, + complexity: report.aggregate.cyclomatic || 0, + halstead: report.aggregate.halstead?.difficulty || 0, + sloc: report.aggregate.sloc?.logical || 0, + maintainability: report.maintainability || 0 + }); + } + } catch { + // JSON parsing failed for this file + } + } catch { + // Tool execution failed for this file + } + } + + return results.length > 0 ? results : null; +} + +/** + * Get user-friendly message about missing tools (language-aware) + * + * @param {string[]} missingTools - Array of missing tool names + * @param {string[]} [languages] - Detected languages (for context in message) + * @returns {string} Formatted message + */ +function getMissingToolsMessage(missingTools, languages = null) { + if (!missingTools || missingTools.length === 0) { + return ''; + } + + // Filter to only known tools + const validTools = missingTools.filter(t => CLI_TOOLS[t]); + if (validTools.length === 0) { + return ''; + } + + let message = '\n## Enhanced Analysis Available\n\n'; + + if (languages && languages.length > 0) { + message += `Detected project languages: ${languages.join(', ')}\n\n`; + } + + message += 'For deeper analysis, consider installing:\n\n'; + + for (const toolName of validTools) { + const tool = CLI_TOOLS[toolName]; + if (tool) { + message += `- **${tool.name}**: ${tool.description}\n`; + message += ` Install: \`${tool.installHint}\`\n`; + } + } + + message += '\nThese tools are optional and enhance detection capabilities.\n'; + + return message; +} + +/** + * Get all CLI tool definitions + * + * @returns {Object} CLI tool definitions + */ +function getToolDefinitions() { + return { ...CLI_TOOLS }; +} + +/** + * Get supported languages list + * + * @returns {string[]} Array of supported language names + */ +function getSupportedLanguages() { + return [...SUPPORTED_LANGUAGES]; +} + +module.exports = { + detectAvailableTools, + detectProjectLanguages, + getToolsForLanguages, + getToolAvailabilityForRepo, + runDuplicateDetection, + runDependencyAnalysis, + runComplexityAnalysis, + getMissingToolsMessage, + getToolDefinitions, + getSupportedLanguages, + clearCache, + // Exported for testing + isToolAvailable, + CLI_TOOLS, + SUPPORTED_LANGUAGES +}; diff --git a/plugins/deslop-around/lib/patterns/pipeline.js b/plugins/deslop-around/lib/patterns/pipeline.js new file mode 100644 index 00000000..1b630ad7 --- /dev/null +++ b/plugins/deslop-around/lib/patterns/pipeline.js @@ -0,0 +1,553 @@ +/** + * Slop Detection Pipeline + * + * 3-phase detection pipeline orchestrator: + * - Phase 1 (built-in): regex patterns + multi-pass analyzers - always runs + * - Phase 2 (optional): CLI tools (jscpd, madge, escomplex) - if available + * - Phase 3 (LLM handoff): certainty-tagged findings for agent review + * + * Inherits modes from deslop-around: report (analyze only) vs apply (fix issues) + * + * @module patterns/pipeline + * @author Avi Fenesh + * @license MIT + */ + +const path = require('path'); +const fs = require('fs'); +const slopPatterns = require('./slop-patterns'); +const analyzers = require('./slop-analyzers'); + +/** + * Certainty levels for findings + * HIGH: Single regex match - definitive + * MEDIUM: Multi-pass analysis - requires context + * LOW: Heuristic/CLI tool - needs verification + */ +const CERTAINTY = { + HIGH: 'HIGH', + MEDIUM: 'MEDIUM', + LOW: 'LOW' +}; + +/** + * Thoroughness levels + * quick: Phase 1 regex only - fastest + * normal: Phase 1 + multi-pass analyzers - balanced + * deep: Phase 1 + Phase 2 CLI tools (if available) - thorough + */ +const THOROUGHNESS = { + QUICK: 'quick', + NORMAL: 'normal', + DEEP: 'deep' +}; + +/** + * Run the slop detection pipeline + * + * @param {string} repoPath - Repository root path + * @param {Object} options - Pipeline options + * @param {string} [options.thoroughness='normal'] - quick | normal | deep + * @param {string[]} [options.targetFiles] - Specific files to analyze (defaults to all source files) + * @param {string} [options.language] - Filter to specific language + * @param {string} [options.mode='report'] - report | apply + * @param {Object} [options.cliTools] - Pre-detected CLI tools (from detectAvailableTools) + * @returns {Object} Pipeline results: { findings, summary, phase3Prompt, missingTools } + */ +function runPipeline(repoPath, options = {}) { + const thoroughness = options.thoroughness || THOROUGHNESS.NORMAL; + const mode = options.mode || 'report'; + const language = options.language || null; + + const findings = []; + const missingTools = []; + let cliTools = options.cliTools || null; + + // Get target files + let targetFiles = options.targetFiles; + if (!targetFiles || targetFiles.length === 0) { + const result = analyzers.countSourceFiles(repoPath, { + maxFiles: 1000, + includeTests: false + }); + targetFiles = result.files; + } + + // Phase 1: Built-in regex patterns (always runs) + const phase1Results = runPhase1(repoPath, targetFiles, language); + findings.push(...phase1Results); + + // Phase 1b: Multi-pass analyzers (if normal or deep) + if (thoroughness !== THOROUGHNESS.QUICK) { + const multiPassResults = runMultiPassAnalyzers(repoPath, targetFiles); + findings.push(...multiPassResults); + } + + // Phase 2: CLI tools (only if deep and tools available) + if (thoroughness === THOROUGHNESS.DEEP) { + // Lazy-load CLI enhancers to avoid circular dependencies + const cliEnhancers = require('./cli-enhancers'); + + if (!cliTools) { + cliTools = cliEnhancers.detectAvailableTools(); + } + + // Track missing tools for user notification + if (!cliTools.jscpd) missingTools.push('jscpd'); + if (!cliTools.madge) missingTools.push('madge'); + if (!cliTools.escomplex) missingTools.push('escomplex'); + + const phase2Results = runPhase2(repoPath, cliTools, targetFiles); + findings.push(...phase2Results); + } + + // Build summary + const summary = buildSummary(findings); + + // Generate Phase 3 handoff prompt + const phase3Prompt = formatHandoffPrompt(findings, mode); + + return { + findings, + summary, + phase3Prompt, + missingTools, + metadata: { + repoPath, + thoroughness, + mode, + filesAnalyzed: targetFiles.length, + timestamp: new Date().toISOString() + } + }; +} + +/** + * Phase 1: Run built-in regex patterns against target files + * + * @param {string} repoPath - Repository root + * @param {string[]} targetFiles - Files to analyze + * @param {string|null} language - Optional language filter + * @returns {Array} Findings with HIGH certainty + */ +function runPhase1(repoPath, targetFiles, language) { + const findings = []; + + // Get patterns (filtered by language if specified) + const patterns = language + ? slopPatterns.getPatternsForLanguage(language) + : slopPatterns.slopPatterns; + + for (const file of targetFiles) { + // Skip if language filter doesn't match file extension + if (language) { + const fileLanguage = analyzers.detectLanguage(file); + if (fileLanguage !== language && fileLanguage !== 'js') continue; + } + + const filePath = path.isAbsolute(file) ? file : path.join(repoPath, file); + + let content; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch { + continue; // Skip unreadable files + } + + const lines = content.split('\n'); + + for (const [patternName, pattern] of Object.entries(patterns)) { + // Skip multi-pass patterns (handled separately) + if (pattern.requiresMultiPass) continue; + + // Skip if no regex pattern + if (!pattern.pattern) continue; + + // Skip if file matches exclude patterns + if (slopPatterns.isFileExcluded(file, pattern.exclude)) continue; + + // Check each line + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (pattern.pattern.test(line)) { + findings.push({ + file, + line: i + 1, + patternName, + severity: pattern.severity, + certainty: CERTAINTY.HIGH, + description: pattern.description, + autoFix: pattern.autoFix, + content: line.trim().substring(0, 100), + phase: 1 + }); + } + } + } + } + + return findings; +} + +/** + * Run multi-pass analyzers (doc/code ratio, verbosity, etc.) + * + * @param {string} repoPath - Repository root + * @param {string[]} targetFiles - Files to analyze + * @returns {Array} Findings with MEDIUM certainty + */ +function runMultiPassAnalyzers(repoPath, targetFiles) { + const findings = []; + + // Get multi-pass pattern definitions for thresholds + const multiPassPatterns = slopPatterns.getMultiPassPatterns(); + + for (const file of targetFiles) { + const filePath = path.isAbsolute(file) ? file : path.join(repoPath, file); + const lang = analyzers.detectLanguage(file); + + // Skip non-JS files for doc/code ratio (JSDoc specific) + if (lang !== 'js') continue; + + let content; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch { + continue; + } + + // Doc/code ratio analysis + const docCodePattern = multiPassPatterns.doc_code_ratio_js; + if (docCodePattern) { + const docRatioViolations = analyzers.analyzeDocCodeRatio(content, { + minFunctionLines: docCodePattern.minFunctionLines || 3, + maxRatio: docCodePattern.maxRatio || 3.0 + }); + + for (const v of docRatioViolations) { + findings.push({ + file, + line: v.line, + patternName: 'doc_code_ratio_js', + severity: docCodePattern.severity, + certainty: CERTAINTY.MEDIUM, + description: `${docCodePattern.description} (${v.docLines} doc lines / ${v.codeLines} code lines = ${v.ratio}x)`, + autoFix: docCodePattern.autoFix, + content: `Function at line ${v.line}`, + phase: 1, + details: { docLines: v.docLines, codeLines: v.codeLines, ratio: v.ratio } + }); + } + } + + // Verbosity ratio analysis + const verbosityPattern = multiPassPatterns.verbosity_ratio; + if (verbosityPattern) { + const verbosityViolations = analyzers.analyzeVerbosityRatio(content, { + minCodeLines: verbosityPattern.minCodeLines || 3, + maxCommentRatio: verbosityPattern.maxCommentRatio || 2.0, + filePath: file + }); + + for (const v of verbosityViolations) { + findings.push({ + file, + line: v.line, + patternName: 'verbosity_ratio', + severity: verbosityPattern.severity, + certainty: CERTAINTY.MEDIUM, + description: `${verbosityPattern.description} (${v.commentLines} comment lines / ${v.codeLines} code lines = ${v.ratio}x)`, + autoFix: verbosityPattern.autoFix, + content: `Function at line ${v.line}`, + phase: 1, + details: { commentLines: v.commentLines, codeLines: v.codeLines, ratio: v.ratio } + }); + } + } + } + + // Project-level analyzers (run once, not per-file) + const overEngPattern = multiPassPatterns.over_engineering_metrics; + if (overEngPattern) { + const overEngResult = analyzers.analyzeOverEngineering(repoPath, { + fileRatioThreshold: overEngPattern.fileRatioThreshold || 20, + linesPerExportThreshold: overEngPattern.linesPerExportThreshold || 500, + depthThreshold: overEngPattern.depthThreshold || 4 + }); + + for (const v of overEngResult.violations) { + findings.push({ + file: 'project-level', + line: 0, + patternName: 'over_engineering_metrics', + severity: v.severity, + certainty: CERTAINTY.MEDIUM, + description: `Over-engineering: ${v.type} - ${v.value} (threshold: ${v.threshold})`, + autoFix: 'flag', + content: v.value, + phase: 1, + details: v.details + }); + } + } + + // Buzzword inflation analysis + const buzzwordPattern = multiPassPatterns.buzzword_inflation; + if (buzzwordPattern) { + const buzzwordResult = analyzers.analyzeBuzzwordInflation(repoPath, { + minEvidenceMatches: buzzwordPattern.minEvidenceMatches || 2 + }); + + for (const v of buzzwordResult.violations) { + findings.push({ + file: v.file, + line: v.line, + patternName: 'buzzword_inflation', + severity: v.severity, + certainty: CERTAINTY.MEDIUM, + description: v.message, + autoFix: 'flag', + content: v.claim, + phase: 1, + details: { buzzword: v.buzzword, category: v.category, evidenceCount: v.evidenceCount } + }); + } + } + + // Infrastructure without implementation + const infraPattern = multiPassPatterns.infrastructure_without_implementation; + if (infraPattern) { + const infraResult = analyzers.analyzeInfrastructureWithoutImplementation(repoPath); + + for (const v of infraResult.violations) { + findings.push({ + file: v.file, + line: v.line, + patternName: 'infrastructure_without_implementation', + severity: v.severity, + certainty: CERTAINTY.MEDIUM, + description: v.message, + autoFix: 'flag', + content: v.content, + phase: 1, + details: { varName: v.varName, type: v.type } + }); + } + } + + return findings; +} + +/** + * Phase 2: Run CLI tools (if available) + * + * @param {string} repoPath - Repository root + * @param {Object} cliTools - Available CLI tools { jscpd, madge, escomplex } + * @param {string[]} targetFiles - Files to analyze + * @returns {Array} Findings with LOW certainty + */ +function runPhase2(repoPath, cliTools, targetFiles) { + const findings = []; + const cliEnhancers = require('./cli-enhancers'); + + // Duplicate detection with jscpd + if (cliTools.jscpd) { + const duplicates = cliEnhancers.runDuplicateDetection(repoPath); + if (duplicates) { + for (const dup of duplicates) { + findings.push({ + file: dup.firstFile, + line: dup.firstLine, + patternName: 'code_duplication', + severity: 'medium', + certainty: CERTAINTY.LOW, + description: `Code duplication: ${dup.lines} lines duplicated in ${dup.secondFile}:${dup.secondLine}`, + autoFix: 'flag', + content: `${dup.lines} lines duplicated`, + phase: 2, + details: dup + }); + } + } + } + + // Circular dependencies with madge + if (cliTools.madge) { + const circularDeps = cliEnhancers.runDependencyAnalysis(repoPath); + if (circularDeps) { + for (const cycle of circularDeps) { + findings.push({ + file: cycle[0], + line: 0, + patternName: 'circular_dependency', + severity: 'high', + certainty: CERTAINTY.LOW, + description: `Circular dependency: ${cycle.join(' -> ')}`, + autoFix: 'flag', + content: cycle.join(' -> '), + phase: 2, + details: { cycle } + }); + } + } + } + + // Complexity analysis with escomplex + if (cliTools.escomplex) { + const complexityResults = cliEnhancers.runComplexityAnalysis(repoPath, targetFiles); + if (complexityResults) { + for (const result of complexityResults) { + if (result.complexity > 10) { // High cyclomatic complexity threshold + findings.push({ + file: result.file, + line: result.line || 0, + patternName: 'high_complexity', + severity: result.complexity > 20 ? 'high' : 'medium', + certainty: CERTAINTY.LOW, + description: `High cyclomatic complexity: ${result.complexity} in ${result.name}`, + autoFix: 'flag', + content: `${result.name}: complexity ${result.complexity}`, + phase: 2, + details: result + }); + } + } + } + } + + return findings; +} + +/** + * Build summary statistics from findings + * + * @param {Array} findings - All findings + * @returns {Object} Summary statistics + */ +function buildSummary(findings) { + const summary = { + total: findings.length, + bySeverity: { critical: 0, high: 0, medium: 0, low: 0 }, + byCertainty: { HIGH: 0, MEDIUM: 0, LOW: 0 }, + byPhase: { 1: 0, 2: 0 }, + byAutoFix: { remove: 0, replace: 0, add_logging: 0, flag: 0, none: 0 }, + topPatterns: {} + }; + + for (const f of findings) { + summary.bySeverity[f.severity] = (summary.bySeverity[f.severity] || 0) + 1; + summary.byCertainty[f.certainty] = (summary.byCertainty[f.certainty] || 0) + 1; + summary.byPhase[f.phase] = (summary.byPhase[f.phase] || 0) + 1; + summary.byAutoFix[f.autoFix] = (summary.byAutoFix[f.autoFix] || 0) + 1; + summary.topPatterns[f.patternName] = (summary.topPatterns[f.patternName] || 0) + 1; + } + + return summary; +} + +/** + * Format handoff prompt for LLM (Phase 3) + * + * Creates a token-efficient prompt for the agent to review findings. + * Groups by certainty level with action guidance: + * - HIGH: Apply directly (if apply mode) + * - MEDIUM: Verify context before applying + * - LOW: Use judgment, may be false positive + * + * @param {Array} findings - All findings + * @param {string} mode - report | apply + * @returns {string} Formatted prompt + */ +function formatHandoffPrompt(findings, mode) { + if (findings.length === 0) { + return '## Slop Detection Results\n\nNo issues detected.'; + } + + // Group findings by certainty + const byGroup = { + HIGH: findings.filter(f => f.certainty === CERTAINTY.HIGH), + MEDIUM: findings.filter(f => f.certainty === CERTAINTY.MEDIUM), + LOW: findings.filter(f => f.certainty === CERTAINTY.LOW) + }; + + let prompt = '## Slop Detection Results\n\n'; + prompt += `Mode: **${mode}** | Total: ${findings.length} findings\n\n`; + + // HIGH certainty - definitive matches + if (byGroup.HIGH.length > 0) { + prompt += '### HIGH Certainty (Definitive - trust these)\n\n'; + if (mode === 'apply') { + prompt += '_Action: Apply fixes directly for autoFix patterns._\n\n'; + } + prompt += formatFindingsList(byGroup.HIGH); + prompt += '\n'; + } + + // MEDIUM certainty - needs context verification + if (byGroup.MEDIUM.length > 0) { + prompt += '### MEDIUM Certainty (Verify context)\n\n'; + prompt += '_Action: Review surrounding code before applying._\n\n'; + prompt += formatFindingsList(byGroup.MEDIUM); + prompt += '\n'; + } + + // LOW certainty - use judgment + if (byGroup.LOW.length > 0) { + prompt += '### LOW Certainty (Use judgment)\n\n'; + prompt += '_Action: May be false positives. Investigate before acting._\n\n'; + prompt += formatFindingsList(byGroup.LOW); + prompt += '\n'; + } + + // Action summary + prompt += '### Action Summary\n\n'; + const autoFixable = findings.filter(f => f.autoFix && f.autoFix !== 'flag' && f.autoFix !== 'none'); + const needsReview = findings.filter(f => f.autoFix === 'flag' || f.autoFix === 'none'); + + prompt += `- Auto-fixable: ${autoFixable.length}\n`; + prompt += `- Needs manual review: ${needsReview.length}\n`; + + return prompt; +} + +/** + * Format a list of findings for the prompt + * + * @param {Array} findings - Findings to format + * @returns {string} Formatted list + */ +function formatFindingsList(findings) { + // Group by file for compact output + const byFile = {}; + for (const f of findings) { + if (!byFile[f.file]) byFile[f.file] = []; + byFile[f.file].push(f); + } + + let output = ''; + for (const [file, fileFindings] of Object.entries(byFile)) { + output += `**${file}**\n`; + for (const f of fileFindings) { + const fixTag = f.autoFix && f.autoFix !== 'flag' && f.autoFix !== 'none' + ? ` [${f.autoFix}]` + : ''; + output += `- L${f.line}: ${f.description}${fixTag}\n`; + } + output += '\n'; + } + + return output; +} + +module.exports = { + runPipeline, + // Exported for testing + runPhase1, + runMultiPassAnalyzers, + runPhase2, + buildSummary, + formatHandoffPrompt, + // Constants + CERTAINTY, + THOROUGHNESS +}; diff --git a/plugins/next-task/agents/deslop-work.md b/plugins/next-task/agents/deslop-work.md index 3fa012e2..591eb7fd 100644 --- a/plugins/next-task/agents/deslop-work.md +++ b/plugins/next-task/agents/deslop-work.md @@ -11,9 +11,15 @@ Clean AI slop specifically from new work (committed but not pushed to remote). Unlike `/deslop-around` which scans the entire codebase, this agent focuses only on the diff between the current branch and origin/main. -**Architecture**: Sonnet discovers → Haiku executes -- This agent (sonnet): Analyze code, identify issues, create fix list -- simple-fixer (haiku): Execute the fixes mechanically +**Architecture**: Pipeline-driven detection with certainty-tagged findings +- Phase 1: Built-in regex + multi-pass analyzers (always runs) +- Phase 2: Optional CLI tools (jscpd, madge, escomplex) - if available +- Phase 3: LLM review with structured handoff + +Certainty levels guide action: +- **HIGH**: Trust these - apply fixes directly (for autoFix patterns) +- **MEDIUM**: Verify context - review surrounding code before applying +- **LOW**: Use judgment - may be false positives, investigate first ## Scope @@ -36,135 +42,133 @@ else fi ``` -## Phase 2: Load Slop Patterns +## Phase 2: Run Detection Pipeline -Use the existing slop patterns library: +Use the pipeline orchestrator with changed files: ```javascript -const { - slopPatterns, - getPatternsForLanguage, - isFileExcluded -} = require('${CLAUDE_PLUGIN_ROOT}/lib/patterns/slop-patterns.js'); +const { runPipeline, THOROUGHNESS } = require('${CLAUDE_PLUGIN_ROOT}/lib/patterns/pipeline.js'); + +// Determine mode from args (default: apply for deslop-work) +const mode = args.mode || 'apply'; + +// Run pipeline on changed files only +const result = runPipeline(repoPath, { + thoroughness: THOROUGHNESS.NORMAL, // Regex + multi-pass analyzers + targetFiles: changedFiles, + mode: mode +}); + +console.log(`## Deslop Work Analysis\n`); +console.log(`Files analyzed: ${result.metadata.filesAnalyzed}`); +console.log(`Total findings: ${result.summary.total}`); +console.log(`\nBy Certainty:`); +console.log(`- HIGH: ${result.summary.byCertainty.HIGH}`); +console.log(`- MEDIUM: ${result.summary.byCertainty.MEDIUM}`); +console.log(`- LOW: ${result.summary.byCertainty.LOW}`); ``` -## Phase 3: Analyze Changed Files +## Phase 3: Process Findings by Certainty + +### HIGH Certainty (Trust and Apply) -For each changed file: -1. Determine language from extension -2. Get applicable patterns (language-specific + universal) -3. Scan for pattern matches -4. Record issues with file, line, severity +For HIGH certainty findings with autoFix strategies (remove, replace, add_logging): ```javascript -const issues = []; - -for (const file of changedFiles) { - const ext = file.split('.').pop(); - const language = getLanguageFromExtension(ext); - const patterns = getPatternsForLanguage(language); - - const content = await readFile(file); - const lines = content.split('\n'); - - for (const [patternName, pattern] of Object.entries(patterns)) { - // Skip if file matches exclude patterns - if (isFileExcluded(file, pattern.exclude)) continue; - - // Check each line - lines.forEach((line, idx) => { - if (pattern.pattern && pattern.pattern.test(line)) { - issues.push({ - file, - line: idx + 1, - pattern: patternName, - severity: pattern.severity, - description: pattern.description, - autoFix: pattern.autoFix, - content: line.trim().substring(0, 100) - }); - } - }); - } +const highCertaintyFixable = result.findings.filter(f => + f.certainty === 'HIGH' && + f.autoFix && + f.autoFix !== 'flag' && + f.autoFix !== 'none' +); + +if (highCertaintyFixable.length > 0) { + console.log(`\n### Auto-fixing ${highCertaintyFixable.length} HIGH certainty issues`); + + const fixList = { + fixes: highCertaintyFixable.map(f => ({ + file: f.file, + line: f.line, + action: f.autoFix === 'remove' ? 'remove-line' : f.autoFix, + reason: f.description, + content: f.content + })), + commitMessage: 'fix: clean up AI slop (console.log, TODOs, etc.)' + }; + + // Delegate to simple-fixer (haiku) for execution + const fixResult = await Task({ + subagent_type: 'simple-fixer', + prompt: JSON.stringify(fixList), + model: 'haiku' + }); } ``` -## Phase 4: Prioritize by Severity +### MEDIUM Certainty (Verify Before Applying) -Group issues by severity: -- **critical**: Security issues (hardcoded secrets) -- **high**: Empty catch blocks, placeholder text, process.exit -- **medium**: Console debugging, commented code -- **low**: Magic numbers, trailing whitespace +For MEDIUM certainty findings, verify context before deciding: -## Phase 5: Create Fix List +```javascript +const mediumCertainty = result.findings.filter(f => f.certainty === 'MEDIUM'); -Build a structured fix list for issues that can be auto-fixed: +if (mediumCertainty.length > 0) { + console.log(`\n### MEDIUM Certainty (${mediumCertainty.length} findings - verify context)`); -```javascript -function createFixList(issues) { - const fixList = { - fixes: [], - commitMessage: 'fix: clean up AI slop (debug statements, TODOs, etc.)' - }; + for (const finding of mediumCertainty) { + // Read surrounding context + const context = await Read({ + file_path: finding.file, + offset: Math.max(1, finding.line - 5), + limit: 15 + }); - for (const issue of issues) { - if (!issue.autoFix) continue; // Skip issues that need manual review - - switch (issue.autoFix) { - case 'remove': - fixList.fixes.push({ - file: issue.file, - line: issue.line, - action: 'remove-line', - reason: issue.description - }); - break; - - case 'replace': - fixList.fixes.push({ - file: issue.file, - line: issue.line, - action: 'replace', - old: issue.content, - new: issue.replacement || '', - reason: issue.description - }); - break; - } - } + console.log(`\n**${finding.file}:${finding.line}**`); + console.log(`Pattern: ${finding.patternName}`); + console.log(`Description: ${finding.description}`); + console.log(`Context:\n\`\`\`\n${context}\n\`\`\``); - return fixList; + // Make judgment call based on context + // If clearly slop, add to fix list + // If ambiguous, flag for manual review + } } ``` -## Phase 6: Delegate Fixes to simple-fixer (haiku) +### LOW Certainty (Investigate) + +For LOW certainty findings (usually from CLI tools), investigate carefully: ```javascript -async function applyFixes(fixList, manualIssues) { - if (fixList.fixes.length === 0) { - console.log("No auto-fixable issues found."); - return { applied: 0, manual: manualIssues.length }; +const lowCertainty = result.findings.filter(f => f.certainty === 'LOW'); + +if (lowCertainty.length > 0) { + console.log(`\n### LOW Certainty (${lowCertainty.length} findings - investigate)`); + console.log('_These may be false positives. Use judgment before acting._\n'); + + for (const finding of lowCertainty) { + console.log(`- **${finding.file}:${finding.line}**: ${finding.description}`); + if (finding.details) { + console.log(` Details: ${JSON.stringify(finding.details)}`); + } } +} +``` - console.log(`\n## Delegating ${fixList.fixes.length} fixes to simple-fixer (haiku)`); +## Phase 4: Handle Missing Tools - const result = await Task({ - subagent_type: 'simple-fixer', - prompt: JSON.stringify(fixList), - model: 'haiku' - }); +If pipeline reports missing CLI tools, notify user at end: - console.log(`✓ Applied ${result.applied} fixes`); - if (result.failed > 0) { - console.log(`⚠ Failed to apply ${result.failed} fixes`); - } +```javascript +const { getMissingToolsMessage } = require('${CLAUDE_PLUGIN_ROOT}/lib/patterns/cli-enhancers.js'); - return result; +if (result.missingTools && result.missingTools.length > 0) { + const message = getMissingToolsMessage(result.missingTools); + console.log(message); } ``` -## Phase 7: Report Results +## Phase 5: Report Results ```markdown ## Deslop Work Report @@ -172,15 +176,22 @@ async function applyFixes(fixList, manualIssues) { ### Summary | Category | Count | |----------|-------| -| Auto-fixed | ${autoFixed} | +| HIGH certainty (auto-fixed) | ${highFixed} | +| MEDIUM certainty (reviewed) | ${mediumReviewed} | +| LOW certainty (flagged) | ${lowFlagged} | | Manual review needed | ${manualCount} | -| Failed | ${failedCount} | -### Auto-Fixed Issues -${fixedIssues.map(i => `- ✓ **${i.file}:${i.line}** - ${i.reason}`).join('\n')} +### Fixed Issues (HIGH Certainty) +${fixedIssues.map(i => `- **${i.file}:${i.line}** - ${i.reason}`).join('\n')} + +### Reviewed Issues (MEDIUM Certainty) +${reviewedIssues.map(i => `- **${i.file}:${i.line}** - ${i.description} - ${i.action}`).join('\n')} + +### Flagged for Investigation (LOW Certainty) +${lowCertaintyIssues.map(i => `- **${i.file}:${i.line}** - ${i.description}`).join('\n')} ### Requires Manual Review -${manualIssues.map(i => `- ⚠ **${i.file}:${i.line}** - ${i.description}\n \`${i.content}\``).join('\n')} +${manualIssues.map(i => `- **${i.file}:${i.line}** - ${i.description}\n \`${i.content}\``).join('\n')} ``` ## Output Format (JSON) @@ -190,23 +201,21 @@ ${manualIssues.map(i => `- ⚠ **${i.file}:${i.line}** - ${i.description}\n \`$ "scope": "new-work-only", "baseBranch": "origin/main", "filesAnalyzed": 5, - "issues": [ - { - "file": "src/feature.ts", - "line": 42, - "pattern": "console_debugging", - "severity": "medium", - "description": "Console.log statements left in production code", - "autoFix": "remove", - "content": "console.log('debug:', data)" - } - ], + "pipeline": { + "thoroughness": "normal", + "mode": "apply" + }, "summary": { - "critical": 0, - "high": 1, - "medium": 3, - "low": 2 - } + "total": 12, + "byCertainty": { "HIGH": 8, "MEDIUM": 3, "LOW": 1 }, + "bySeverity": { "critical": 0, "high": 2, "medium": 7, "low": 3 } + }, + "actions": { + "autoFixed": 6, + "manualReview": 4, + "flagged": 2 + }, + "missingTools": ["jscpd", "escomplex"] } ``` @@ -216,13 +225,13 @@ This agent is called: 1. **Before first review round** - After implementation-agent completes 2. **After each review iteration** - After review-orchestrator finds issues and fixes are applied -## Behavior +## Behavior by Certainty Level -- **Analyze with sonnet** - Identify issues and create fix list -- **Execute with haiku** - Delegate simple fixes to simple-fixer -- Auto-fix safe patterns (console.log removal, TODO cleanup, etc.) -- Report issues requiring manual review -- Critical security issues flagged for human attention +| Certainty | Source | Action | +|-----------|--------|--------| +| HIGH | Phase 1 regex | Auto-fix directly | +| MEDIUM | Multi-pass analyzers | Verify context, then fix or flag | +| LOW | CLI tools (Phase 2) | Investigate, likely flag | ## Language Detection @@ -253,20 +262,22 @@ function getLanguageFromExtension(ext) { ## Success Criteria - Only analyzes files in current branch diff (not entire repo) -- Uses existing slop-patterns.js library -- **Sonnet analyzes, haiku executes** - cost-efficient architecture -- Auto-fixes safe patterns via simple-fixer delegation -- Reports issues requiring manual review +- Uses pipeline orchestrator for structured detection +- Respects certainty levels for action decisions +- **HIGH certainty**: Auto-fix via simple-fixer delegation +- **MEDIUM certainty**: Verify context before applying +- **LOW certainty**: Flag for investigation +- Reports missing CLI tools at end (non-blocking) - Returns structured JSON for orchestrator consumption ## Architecture Notes -This agent uses sonnet for analysis because: -- Pattern detection requires understanding context -- Creating fix lists needs judgment about safety -- Identifying what CAN'T be auto-fixed needs reasoning +This agent uses **sonnet** for analysis because: +- Certainty-based decision making requires judgment +- Context verification needs understanding +- Creating fix lists requires reasoning about safety -simple-fixer uses haiku because: +**simple-fixer** uses **haiku** because: - Executing pre-defined edits is mechanical - No judgment calls needed - Fast and cost-efficient for batch operations diff --git a/plugins/next-task/lib/config/index.js b/plugins/next-task/lib/config/index.js new file mode 100644 index 00000000..25bffeb2 --- /dev/null +++ b/plugins/next-task/lib/config/index.js @@ -0,0 +1,14 @@ +/** + * Configuration Module + * + * Placeholder for future configuration management. + * This module exists to satisfy lib/index.js imports. + * + * @module config + * @author Avi Fenesh + * @license MIT + */ + +module.exports = { + // Configuration will be added as needed +}; diff --git a/plugins/next-task/lib/index.js b/plugins/next-task/lib/index.js index f927426e..860cb976 100644 --- a/plugins/next-task/lib/index.js +++ b/plugins/next-task/lib/index.js @@ -14,6 +14,8 @@ const detectPlatform = require('./platform/detect-platform'); const verifyTools = require('./platform/verify-tools'); const reviewPatterns = require('./patterns/review-patterns'); const slopPatterns = require('./patterns/slop-patterns'); +const pipeline = require('./patterns/pipeline'); +const cliEnhancers = require('./patterns/cli-enhancers'); const workflowState = require('./state/workflow-state'); const contextOptimizer = require('./utils/context-optimizer'); const shellEscape = require('./utils/shell-escape'); @@ -65,7 +67,32 @@ const patterns = { * Slop patterns for AI-generated code detection * @see module:patterns/slop-patterns */ - slop: slopPatterns + slop: slopPatterns, + + /** + * Slop detection pipeline orchestrator + * @see module:patterns/pipeline + */ + pipeline: { + runPipeline: pipeline.runPipeline, + CERTAINTY: pipeline.CERTAINTY, + THOROUGHNESS: pipeline.THOROUGHNESS, + formatHandoffPrompt: pipeline.formatHandoffPrompt, + buildSummary: pipeline.buildSummary + }, + + /** + * Optional CLI tool enhancers for deep analysis + * @see module:patterns/cli-enhancers + */ + cliEnhancers: { + detectAvailableTools: cliEnhancers.detectAvailableTools, + runDuplicateDetection: cliEnhancers.runDuplicateDetection, + runDependencyAnalysis: cliEnhancers.runDependencyAnalysis, + runComplexityAnalysis: cliEnhancers.runComplexityAnalysis, + getMissingToolsMessage: cliEnhancers.getMissingToolsMessage, + CLI_TOOLS: cliEnhancers.CLI_TOOLS + } }; /** @@ -161,6 +188,8 @@ module.exports = { verifyTools, reviewPatterns, slopPatterns, + pipeline, + cliEnhancers, workflowState, contextOptimizer, shellEscape, diff --git a/plugins/next-task/lib/patterns/cli-enhancers.js b/plugins/next-task/lib/patterns/cli-enhancers.js new file mode 100644 index 00000000..6cfac1da --- /dev/null +++ b/plugins/next-task/lib/patterns/cli-enhancers.js @@ -0,0 +1,602 @@ +/** + * CLI Enhancers for Slop Detection Pipeline + * + * Optional CLI tool integration for Phase 2 detection. + * All tools are user-installed globally - zero npm dependencies for this module. + * Functions gracefully degrade when tools are not available. + * + * Supported languages: javascript, typescript, python, rust, go + * + * @module patterns/cli-enhancers + * @author Avi Fenesh + * @license MIT + */ + +const { execSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const { escapeDoubleQuotes } = require('../utils/shell-escape'); + +/** + * Cache for tool availability (per-repo) + * Key: repoPath, Value: { tools: {...}, languages: [...], timestamp: Date } + */ +const toolCache = new Map(); +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Supported languages (must match slop-patterns.js) + */ +const SUPPORTED_LANGUAGES = ['javascript', 'typescript', 'python', 'rust', 'go']; + +/** + * CLI tool definitions organized by language + * Only includes tools for supported languages + */ +const CLI_TOOLS = { + // Cross-language tools + jscpd: { + name: 'jscpd', + description: 'Copy/paste detector for code duplication', + checkCommand: 'jscpd --version', + installHint: 'npm install -g jscpd', + languages: ['javascript', 'typescript', 'python', 'go', 'rust'] + }, + + // JavaScript/TypeScript tools + madge: { + name: 'madge', + description: 'Circular dependency detector', + checkCommand: 'madge --version', + installHint: 'npm install -g madge', + languages: ['javascript', 'typescript'] + }, + escomplex: { + name: 'escomplex', + description: 'Cyclomatic complexity analyzer', + checkCommand: 'escomplex --version', + installHint: 'npm install -g escomplex', + languages: ['javascript'] + }, + + // Python tools + pylint: { + name: 'pylint', + description: 'Python linter with complexity analysis', + checkCommand: 'pylint --version', + installHint: 'pip install pylint', + languages: ['python'] + }, + radon: { + name: 'radon', + description: 'Python complexity and maintainability metrics', + checkCommand: 'radon --version', + installHint: 'pip install radon', + languages: ['python'] + }, + + // Go tools + golangci_lint: { + name: 'golangci-lint', + description: 'Go linters aggregator with complexity checks', + checkCommand: 'golangci-lint --version', + installHint: 'go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest', + languages: ['go'] + }, + + // Rust tools + clippy: { + name: 'cargo-clippy', + description: 'Rust linter with code smell detection', + checkCommand: 'cargo clippy --version', + installHint: 'rustup component add clippy', + languages: ['rust'] + } +}; + +/** + * Check if a CLI tool is available in PATH + * + * @param {string} command - Command to check (e.g., 'jscpd --version') + * @returns {boolean} True if tool is available + */ +function isToolAvailable(command) { + try { + execSync(command, { + stdio: 'pipe', + timeout: 5000, + windowsHide: true + }); + return true; + } catch { + return false; + } +} + +/** + * Get cache key for a repo + * @param {string} repoPath - Repository root path + * @returns {string} Cache key + */ +function getCacheKey(repoPath) { + return path.resolve(repoPath); +} + +/** + * Check if cache is valid + * @param {Object} cacheEntry - Cache entry + * @returns {boolean} True if cache is still valid + */ +function isCacheValid(cacheEntry) { + if (!cacheEntry) return false; + return Date.now() - cacheEntry.timestamp < CACHE_TTL_MS; +} + +/** + * Clear the tool cache (useful for testing) + */ +function clearCache() { + toolCache.clear(); +} + +/** + * Detect primary language(s) of a repository based on file extensions and config files + * + * @param {string} repoPath - Repository root path + * @returns {string[]} Array of detected languages (only supported ones) + */ +function detectProjectLanguages(repoPath) { + const languages = new Set(); + + // Check for language-specific config files + const configIndicators = { + 'package.json': ['javascript', 'typescript'], + 'tsconfig.json': ['typescript'], + 'requirements.txt': ['python'], + 'setup.py': ['python'], + 'pyproject.toml': ['python'], + 'Pipfile': ['python'], + 'go.mod': ['go'], + 'go.sum': ['go'], + 'Cargo.toml': ['rust'] + }; + + for (const [file, langs] of Object.entries(configIndicators)) { + if (fs.existsSync(path.join(repoPath, file))) { + langs.forEach(l => languages.add(l)); + } + } + + // If no config files found, scan for source files + if (languages.size === 0) { + const extensionMap = { + '.js': 'javascript', + '.jsx': 'javascript', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.ts': 'typescript', + '.tsx': 'typescript', + '.py': 'python', + '.go': 'go', + '.rs': 'rust' + }; + + // Quick scan of top-level and src/ directories + const dirsToScan = [repoPath, path.join(repoPath, 'src'), path.join(repoPath, 'lib')]; + + for (const dir of dirsToScan) { + if (!fs.existsSync(dir)) continue; + try { + const files = fs.readdirSync(dir); + for (const file of files) { + const ext = path.extname(file).toLowerCase(); + if (extensionMap[ext]) { + languages.add(extensionMap[ext]); + } + } + } catch { + // Directory not readable + } + } + } + + // Filter to only supported languages + const result = Array.from(languages).filter(l => SUPPORTED_LANGUAGES.includes(l)); + + // Default to javascript if nothing detected + if (result.length === 0) { + result.push('javascript'); + } + + return result; +} + +/** + * Get tools relevant for specific languages + * + * @param {string[]} languages - Array of language names + * @returns {Object} Filtered CLI_TOOLS for the specified languages + */ +function getToolsForLanguages(languages) { + const relevant = {}; + + for (const [toolName, tool] of Object.entries(CLI_TOOLS)) { + if (tool.languages.some(lang => languages.includes(lang))) { + relevant[toolName] = tool; + } + } + + return relevant; +} + +/** + * Detect which CLI tools are available on the system + * Uses cache when available + * + * @param {string[]} [languages] - Optional languages to filter tools for + * @param {string} [repoPath] - Optional repo path for caching + * @returns {Object} Object with tool names as keys and availability as boolean values + */ +function detectAvailableTools(languages = null, repoPath = null) { + // Check cache if repoPath provided + if (repoPath) { + const cacheKey = getCacheKey(repoPath); + const cached = toolCache.get(cacheKey); + if (isCacheValid(cached)) { + // Return cached tools filtered by languages if specified + if (languages) { + const relevantTools = getToolsForLanguages(languages); + const filtered = {}; + for (const name of Object.keys(relevantTools)) { + filtered[name] = cached.tools[name] || false; + } + return filtered; + } + return { ...cached.tools }; + } + } + + // Get tools to check + const toolsToCheck = languages ? getToolsForLanguages(languages) : CLI_TOOLS; + const result = {}; + + for (const [toolName, tool] of Object.entries(toolsToCheck)) { + result[toolName] = isToolAvailable(tool.checkCommand); + } + + // Update cache if repoPath provided + if (repoPath) { + const cacheKey = getCacheKey(repoPath); + const existing = toolCache.get(cacheKey) || {}; + toolCache.set(cacheKey, { + tools: { ...existing.tools, ...result }, + languages: languages || existing.languages || [], + timestamp: Date.now() + }); + } + + return result; +} + +/** + * Get tool availability for a specific repo (with caching) + * + * @param {string} repoPath - Repository root path + * @param {Object} [options] - Options + * @param {boolean} [options.forceRefresh=false] - Force cache refresh + * @returns {{ available: Object, missing: string[], languages: string[] }} Tool availability info + */ +function getToolAvailabilityForRepo(repoPath, options = {}) { + const cacheKey = getCacheKey(repoPath); + + // Check cache unless force refresh + if (!options.forceRefresh) { + const cached = toolCache.get(cacheKey); + if (isCacheValid(cached) && cached.languages && cached.languages.length > 0) { + const relevantTools = getToolsForLanguages(cached.languages); + const missing = Object.keys(relevantTools).filter(t => !cached.tools[t]); + return { + available: { ...cached.tools }, + missing, + languages: [...cached.languages] + }; + } + } + + // Detect languages + const languages = detectProjectLanguages(repoPath); + + // Detect tools for those languages + const available = detectAvailableTools(languages, repoPath); + + // Find missing tools + const relevantTools = getToolsForLanguages(languages); + const missing = Object.keys(relevantTools).filter(t => !available[t]); + + // Update cache + toolCache.set(cacheKey, { + tools: available, + languages, + timestamp: Date.now() + }); + + return { available, missing, languages }; +} + +/** + * Run duplicate code detection using jscpd + * + * @param {string} repoPath - Repository root path + * @param {Object} options - Options + * @param {number} [options.minLines=5] - Minimum lines for duplicate detection + * @param {number} [options.minTokens=50] - Minimum tokens for duplicate detection + * @returns {Array|null} Duplicates found, or null if tool not available + */ +function runDuplicateDetection(repoPath, options = {}) { + if (!isToolAvailable(CLI_TOOLS.jscpd.checkCommand)) { + return null; + } + + const minLines = options.minLines || 5; + const minTokens = options.minTokens || 50; + + try { + // Run jscpd with JSON output + // Escape repoPath to prevent command injection + const outputPath = process.platform === 'win32' ? 'NUL' : '/dev/null'; + const safeRepoPath = escapeDoubleQuotes(repoPath); + const command = `jscpd "${safeRepoPath}" --min-lines ${minLines} --min-tokens ${minTokens} --reporters json --output ${outputPath} --silent 2>&1`; + + const result = execSync(command, { + stdio: 'pipe', + timeout: 60000, + windowsHide: true, + cwd: repoPath, + encoding: 'utf8' + }); + + // Parse JSON output + try { + const report = JSON.parse(result); + const duplicates = []; + + if (report.duplicates) { + for (const dup of report.duplicates) { + duplicates.push({ + firstFile: dup.firstFile?.name || 'unknown', + firstLine: dup.firstFile?.start || 0, + secondFile: dup.secondFile?.name || 'unknown', + secondLine: dup.secondFile?.start || 0, + lines: dup.lines || 0, + tokens: dup.tokens || 0, + fragment: dup.fragment?.substring(0, 100) || '' + }); + } + } + + return duplicates; + } catch { + // JSON parsing failed, return empty array + return []; + } + } catch { + // Tool execution failed + return null; + } +} + +/** + * Run circular dependency detection using madge + * + * @param {string} repoPath - Repository root path + * @param {Object} options - Options + * @param {string} [options.entry] - Entry file (defaults to src/index.js or index.js) + * @returns {Array|null} Circular dependency cycles, or null if tool not available + */ +function runDependencyAnalysis(repoPath, options = {}) { + if (!isToolAvailable(CLI_TOOLS.madge.checkCommand)) { + return null; + } + + // Determine entry point + let entry = options.entry; + if (!entry) { + const possibleEntries = [ + 'src/index.js', + 'src/index.ts', + 'index.js', + 'index.ts', + 'lib/index.js', + 'main.js' + ]; + + for (const e of possibleEntries) { + if (fs.existsSync(path.join(repoPath, e))) { + entry = e; + break; + } + } + } + + if (!entry) { + // No entry point found, scan entire directory + entry = '.'; + } + + try { + // Run madge with circular flag and JSON output + // Escape entry path to prevent command injection + const safeEntry = escapeDoubleQuotes(entry); + const command = `madge --circular --json "${safeEntry}"`; + + const result = execSync(command, { + stdio: 'pipe', + timeout: 60000, + windowsHide: true, + cwd: repoPath, + encoding: 'utf8' + }); + + // Parse JSON output + try { + const cycles = JSON.parse(result); + // madge returns array of arrays (each cycle is an array of file paths) + return Array.isArray(cycles) ? cycles : []; + } catch { + return []; + } + } catch { + // Tool execution failed + return null; + } +} + +/** + * Run complexity analysis using escomplex + * + * @param {string} repoPath - Repository root path + * @param {string[]} targetFiles - Files to analyze + * @param {Object} options - Options + * @returns {Array|null} Complexity results, or null if tool not available + */ +function runComplexityAnalysis(repoPath, targetFiles, options = {}) { + if (!isToolAvailable(CLI_TOOLS.escomplex.checkCommand)) { + return null; + } + + const results = []; + + // escomplex works on individual files + for (const file of targetFiles) { + // Only analyze JS/TS files + if (!file.match(/\.[jt]sx?$/)) continue; + + const filePath = path.isAbsolute(file) ? file : path.join(repoPath, file); + + try { + // Escape file path to prevent command injection + const safeFilePath = escapeDoubleQuotes(filePath); + const command = `escomplex "${safeFilePath}" --format json`; + + const result = execSync(command, { + stdio: 'pipe', + timeout: 30000, + windowsHide: true, + cwd: repoPath, + encoding: 'utf8' + }); + + try { + const report = JSON.parse(result); + + // Extract function-level complexity + if (report.functions) { + for (const fn of report.functions) { + results.push({ + file, + name: fn.name || 'anonymous', + line: fn.line || 0, + complexity: fn.cyclomatic || 0, + halstead: fn.halstead?.difficulty || 0, + sloc: fn.sloc?.logical || 0 + }); + } + } + + // Also include module-level metrics + if (report.aggregate) { + results.push({ + file, + name: 'module', + line: 0, + complexity: report.aggregate.cyclomatic || 0, + halstead: report.aggregate.halstead?.difficulty || 0, + sloc: report.aggregate.sloc?.logical || 0, + maintainability: report.maintainability || 0 + }); + } + } catch { + // JSON parsing failed for this file + } + } catch { + // Tool execution failed for this file + } + } + + return results.length > 0 ? results : null; +} + +/** + * Get user-friendly message about missing tools (language-aware) + * + * @param {string[]} missingTools - Array of missing tool names + * @param {string[]} [languages] - Detected languages (for context in message) + * @returns {string} Formatted message + */ +function getMissingToolsMessage(missingTools, languages = null) { + if (!missingTools || missingTools.length === 0) { + return ''; + } + + // Filter to only known tools + const validTools = missingTools.filter(t => CLI_TOOLS[t]); + if (validTools.length === 0) { + return ''; + } + + let message = '\n## Enhanced Analysis Available\n\n'; + + if (languages && languages.length > 0) { + message += `Detected project languages: ${languages.join(', ')}\n\n`; + } + + message += 'For deeper analysis, consider installing:\n\n'; + + for (const toolName of validTools) { + const tool = CLI_TOOLS[toolName]; + if (tool) { + message += `- **${tool.name}**: ${tool.description}\n`; + message += ` Install: \`${tool.installHint}\`\n`; + } + } + + message += '\nThese tools are optional and enhance detection capabilities.\n'; + + return message; +} + +/** + * Get all CLI tool definitions + * + * @returns {Object} CLI tool definitions + */ +function getToolDefinitions() { + return { ...CLI_TOOLS }; +} + +/** + * Get supported languages list + * + * @returns {string[]} Array of supported language names + */ +function getSupportedLanguages() { + return [...SUPPORTED_LANGUAGES]; +} + +module.exports = { + detectAvailableTools, + detectProjectLanguages, + getToolsForLanguages, + getToolAvailabilityForRepo, + runDuplicateDetection, + runDependencyAnalysis, + runComplexityAnalysis, + getMissingToolsMessage, + getToolDefinitions, + getSupportedLanguages, + clearCache, + // Exported for testing + isToolAvailable, + CLI_TOOLS, + SUPPORTED_LANGUAGES +}; diff --git a/plugins/next-task/lib/patterns/pipeline.js b/plugins/next-task/lib/patterns/pipeline.js new file mode 100644 index 00000000..1b630ad7 --- /dev/null +++ b/plugins/next-task/lib/patterns/pipeline.js @@ -0,0 +1,553 @@ +/** + * Slop Detection Pipeline + * + * 3-phase detection pipeline orchestrator: + * - Phase 1 (built-in): regex patterns + multi-pass analyzers - always runs + * - Phase 2 (optional): CLI tools (jscpd, madge, escomplex) - if available + * - Phase 3 (LLM handoff): certainty-tagged findings for agent review + * + * Inherits modes from deslop-around: report (analyze only) vs apply (fix issues) + * + * @module patterns/pipeline + * @author Avi Fenesh + * @license MIT + */ + +const path = require('path'); +const fs = require('fs'); +const slopPatterns = require('./slop-patterns'); +const analyzers = require('./slop-analyzers'); + +/** + * Certainty levels for findings + * HIGH: Single regex match - definitive + * MEDIUM: Multi-pass analysis - requires context + * LOW: Heuristic/CLI tool - needs verification + */ +const CERTAINTY = { + HIGH: 'HIGH', + MEDIUM: 'MEDIUM', + LOW: 'LOW' +}; + +/** + * Thoroughness levels + * quick: Phase 1 regex only - fastest + * normal: Phase 1 + multi-pass analyzers - balanced + * deep: Phase 1 + Phase 2 CLI tools (if available) - thorough + */ +const THOROUGHNESS = { + QUICK: 'quick', + NORMAL: 'normal', + DEEP: 'deep' +}; + +/** + * Run the slop detection pipeline + * + * @param {string} repoPath - Repository root path + * @param {Object} options - Pipeline options + * @param {string} [options.thoroughness='normal'] - quick | normal | deep + * @param {string[]} [options.targetFiles] - Specific files to analyze (defaults to all source files) + * @param {string} [options.language] - Filter to specific language + * @param {string} [options.mode='report'] - report | apply + * @param {Object} [options.cliTools] - Pre-detected CLI tools (from detectAvailableTools) + * @returns {Object} Pipeline results: { findings, summary, phase3Prompt, missingTools } + */ +function runPipeline(repoPath, options = {}) { + const thoroughness = options.thoroughness || THOROUGHNESS.NORMAL; + const mode = options.mode || 'report'; + const language = options.language || null; + + const findings = []; + const missingTools = []; + let cliTools = options.cliTools || null; + + // Get target files + let targetFiles = options.targetFiles; + if (!targetFiles || targetFiles.length === 0) { + const result = analyzers.countSourceFiles(repoPath, { + maxFiles: 1000, + includeTests: false + }); + targetFiles = result.files; + } + + // Phase 1: Built-in regex patterns (always runs) + const phase1Results = runPhase1(repoPath, targetFiles, language); + findings.push(...phase1Results); + + // Phase 1b: Multi-pass analyzers (if normal or deep) + if (thoroughness !== THOROUGHNESS.QUICK) { + const multiPassResults = runMultiPassAnalyzers(repoPath, targetFiles); + findings.push(...multiPassResults); + } + + // Phase 2: CLI tools (only if deep and tools available) + if (thoroughness === THOROUGHNESS.DEEP) { + // Lazy-load CLI enhancers to avoid circular dependencies + const cliEnhancers = require('./cli-enhancers'); + + if (!cliTools) { + cliTools = cliEnhancers.detectAvailableTools(); + } + + // Track missing tools for user notification + if (!cliTools.jscpd) missingTools.push('jscpd'); + if (!cliTools.madge) missingTools.push('madge'); + if (!cliTools.escomplex) missingTools.push('escomplex'); + + const phase2Results = runPhase2(repoPath, cliTools, targetFiles); + findings.push(...phase2Results); + } + + // Build summary + const summary = buildSummary(findings); + + // Generate Phase 3 handoff prompt + const phase3Prompt = formatHandoffPrompt(findings, mode); + + return { + findings, + summary, + phase3Prompt, + missingTools, + metadata: { + repoPath, + thoroughness, + mode, + filesAnalyzed: targetFiles.length, + timestamp: new Date().toISOString() + } + }; +} + +/** + * Phase 1: Run built-in regex patterns against target files + * + * @param {string} repoPath - Repository root + * @param {string[]} targetFiles - Files to analyze + * @param {string|null} language - Optional language filter + * @returns {Array} Findings with HIGH certainty + */ +function runPhase1(repoPath, targetFiles, language) { + const findings = []; + + // Get patterns (filtered by language if specified) + const patterns = language + ? slopPatterns.getPatternsForLanguage(language) + : slopPatterns.slopPatterns; + + for (const file of targetFiles) { + // Skip if language filter doesn't match file extension + if (language) { + const fileLanguage = analyzers.detectLanguage(file); + if (fileLanguage !== language && fileLanguage !== 'js') continue; + } + + const filePath = path.isAbsolute(file) ? file : path.join(repoPath, file); + + let content; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch { + continue; // Skip unreadable files + } + + const lines = content.split('\n'); + + for (const [patternName, pattern] of Object.entries(patterns)) { + // Skip multi-pass patterns (handled separately) + if (pattern.requiresMultiPass) continue; + + // Skip if no regex pattern + if (!pattern.pattern) continue; + + // Skip if file matches exclude patterns + if (slopPatterns.isFileExcluded(file, pattern.exclude)) continue; + + // Check each line + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (pattern.pattern.test(line)) { + findings.push({ + file, + line: i + 1, + patternName, + severity: pattern.severity, + certainty: CERTAINTY.HIGH, + description: pattern.description, + autoFix: pattern.autoFix, + content: line.trim().substring(0, 100), + phase: 1 + }); + } + } + } + } + + return findings; +} + +/** + * Run multi-pass analyzers (doc/code ratio, verbosity, etc.) + * + * @param {string} repoPath - Repository root + * @param {string[]} targetFiles - Files to analyze + * @returns {Array} Findings with MEDIUM certainty + */ +function runMultiPassAnalyzers(repoPath, targetFiles) { + const findings = []; + + // Get multi-pass pattern definitions for thresholds + const multiPassPatterns = slopPatterns.getMultiPassPatterns(); + + for (const file of targetFiles) { + const filePath = path.isAbsolute(file) ? file : path.join(repoPath, file); + const lang = analyzers.detectLanguage(file); + + // Skip non-JS files for doc/code ratio (JSDoc specific) + if (lang !== 'js') continue; + + let content; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch { + continue; + } + + // Doc/code ratio analysis + const docCodePattern = multiPassPatterns.doc_code_ratio_js; + if (docCodePattern) { + const docRatioViolations = analyzers.analyzeDocCodeRatio(content, { + minFunctionLines: docCodePattern.minFunctionLines || 3, + maxRatio: docCodePattern.maxRatio || 3.0 + }); + + for (const v of docRatioViolations) { + findings.push({ + file, + line: v.line, + patternName: 'doc_code_ratio_js', + severity: docCodePattern.severity, + certainty: CERTAINTY.MEDIUM, + description: `${docCodePattern.description} (${v.docLines} doc lines / ${v.codeLines} code lines = ${v.ratio}x)`, + autoFix: docCodePattern.autoFix, + content: `Function at line ${v.line}`, + phase: 1, + details: { docLines: v.docLines, codeLines: v.codeLines, ratio: v.ratio } + }); + } + } + + // Verbosity ratio analysis + const verbosityPattern = multiPassPatterns.verbosity_ratio; + if (verbosityPattern) { + const verbosityViolations = analyzers.analyzeVerbosityRatio(content, { + minCodeLines: verbosityPattern.minCodeLines || 3, + maxCommentRatio: verbosityPattern.maxCommentRatio || 2.0, + filePath: file + }); + + for (const v of verbosityViolations) { + findings.push({ + file, + line: v.line, + patternName: 'verbosity_ratio', + severity: verbosityPattern.severity, + certainty: CERTAINTY.MEDIUM, + description: `${verbosityPattern.description} (${v.commentLines} comment lines / ${v.codeLines} code lines = ${v.ratio}x)`, + autoFix: verbosityPattern.autoFix, + content: `Function at line ${v.line}`, + phase: 1, + details: { commentLines: v.commentLines, codeLines: v.codeLines, ratio: v.ratio } + }); + } + } + } + + // Project-level analyzers (run once, not per-file) + const overEngPattern = multiPassPatterns.over_engineering_metrics; + if (overEngPattern) { + const overEngResult = analyzers.analyzeOverEngineering(repoPath, { + fileRatioThreshold: overEngPattern.fileRatioThreshold || 20, + linesPerExportThreshold: overEngPattern.linesPerExportThreshold || 500, + depthThreshold: overEngPattern.depthThreshold || 4 + }); + + for (const v of overEngResult.violations) { + findings.push({ + file: 'project-level', + line: 0, + patternName: 'over_engineering_metrics', + severity: v.severity, + certainty: CERTAINTY.MEDIUM, + description: `Over-engineering: ${v.type} - ${v.value} (threshold: ${v.threshold})`, + autoFix: 'flag', + content: v.value, + phase: 1, + details: v.details + }); + } + } + + // Buzzword inflation analysis + const buzzwordPattern = multiPassPatterns.buzzword_inflation; + if (buzzwordPattern) { + const buzzwordResult = analyzers.analyzeBuzzwordInflation(repoPath, { + minEvidenceMatches: buzzwordPattern.minEvidenceMatches || 2 + }); + + for (const v of buzzwordResult.violations) { + findings.push({ + file: v.file, + line: v.line, + patternName: 'buzzword_inflation', + severity: v.severity, + certainty: CERTAINTY.MEDIUM, + description: v.message, + autoFix: 'flag', + content: v.claim, + phase: 1, + details: { buzzword: v.buzzword, category: v.category, evidenceCount: v.evidenceCount } + }); + } + } + + // Infrastructure without implementation + const infraPattern = multiPassPatterns.infrastructure_without_implementation; + if (infraPattern) { + const infraResult = analyzers.analyzeInfrastructureWithoutImplementation(repoPath); + + for (const v of infraResult.violations) { + findings.push({ + file: v.file, + line: v.line, + patternName: 'infrastructure_without_implementation', + severity: v.severity, + certainty: CERTAINTY.MEDIUM, + description: v.message, + autoFix: 'flag', + content: v.content, + phase: 1, + details: { varName: v.varName, type: v.type } + }); + } + } + + return findings; +} + +/** + * Phase 2: Run CLI tools (if available) + * + * @param {string} repoPath - Repository root + * @param {Object} cliTools - Available CLI tools { jscpd, madge, escomplex } + * @param {string[]} targetFiles - Files to analyze + * @returns {Array} Findings with LOW certainty + */ +function runPhase2(repoPath, cliTools, targetFiles) { + const findings = []; + const cliEnhancers = require('./cli-enhancers'); + + // Duplicate detection with jscpd + if (cliTools.jscpd) { + const duplicates = cliEnhancers.runDuplicateDetection(repoPath); + if (duplicates) { + for (const dup of duplicates) { + findings.push({ + file: dup.firstFile, + line: dup.firstLine, + patternName: 'code_duplication', + severity: 'medium', + certainty: CERTAINTY.LOW, + description: `Code duplication: ${dup.lines} lines duplicated in ${dup.secondFile}:${dup.secondLine}`, + autoFix: 'flag', + content: `${dup.lines} lines duplicated`, + phase: 2, + details: dup + }); + } + } + } + + // Circular dependencies with madge + if (cliTools.madge) { + const circularDeps = cliEnhancers.runDependencyAnalysis(repoPath); + if (circularDeps) { + for (const cycle of circularDeps) { + findings.push({ + file: cycle[0], + line: 0, + patternName: 'circular_dependency', + severity: 'high', + certainty: CERTAINTY.LOW, + description: `Circular dependency: ${cycle.join(' -> ')}`, + autoFix: 'flag', + content: cycle.join(' -> '), + phase: 2, + details: { cycle } + }); + } + } + } + + // Complexity analysis with escomplex + if (cliTools.escomplex) { + const complexityResults = cliEnhancers.runComplexityAnalysis(repoPath, targetFiles); + if (complexityResults) { + for (const result of complexityResults) { + if (result.complexity > 10) { // High cyclomatic complexity threshold + findings.push({ + file: result.file, + line: result.line || 0, + patternName: 'high_complexity', + severity: result.complexity > 20 ? 'high' : 'medium', + certainty: CERTAINTY.LOW, + description: `High cyclomatic complexity: ${result.complexity} in ${result.name}`, + autoFix: 'flag', + content: `${result.name}: complexity ${result.complexity}`, + phase: 2, + details: result + }); + } + } + } + } + + return findings; +} + +/** + * Build summary statistics from findings + * + * @param {Array} findings - All findings + * @returns {Object} Summary statistics + */ +function buildSummary(findings) { + const summary = { + total: findings.length, + bySeverity: { critical: 0, high: 0, medium: 0, low: 0 }, + byCertainty: { HIGH: 0, MEDIUM: 0, LOW: 0 }, + byPhase: { 1: 0, 2: 0 }, + byAutoFix: { remove: 0, replace: 0, add_logging: 0, flag: 0, none: 0 }, + topPatterns: {} + }; + + for (const f of findings) { + summary.bySeverity[f.severity] = (summary.bySeverity[f.severity] || 0) + 1; + summary.byCertainty[f.certainty] = (summary.byCertainty[f.certainty] || 0) + 1; + summary.byPhase[f.phase] = (summary.byPhase[f.phase] || 0) + 1; + summary.byAutoFix[f.autoFix] = (summary.byAutoFix[f.autoFix] || 0) + 1; + summary.topPatterns[f.patternName] = (summary.topPatterns[f.patternName] || 0) + 1; + } + + return summary; +} + +/** + * Format handoff prompt for LLM (Phase 3) + * + * Creates a token-efficient prompt for the agent to review findings. + * Groups by certainty level with action guidance: + * - HIGH: Apply directly (if apply mode) + * - MEDIUM: Verify context before applying + * - LOW: Use judgment, may be false positive + * + * @param {Array} findings - All findings + * @param {string} mode - report | apply + * @returns {string} Formatted prompt + */ +function formatHandoffPrompt(findings, mode) { + if (findings.length === 0) { + return '## Slop Detection Results\n\nNo issues detected.'; + } + + // Group findings by certainty + const byGroup = { + HIGH: findings.filter(f => f.certainty === CERTAINTY.HIGH), + MEDIUM: findings.filter(f => f.certainty === CERTAINTY.MEDIUM), + LOW: findings.filter(f => f.certainty === CERTAINTY.LOW) + }; + + let prompt = '## Slop Detection Results\n\n'; + prompt += `Mode: **${mode}** | Total: ${findings.length} findings\n\n`; + + // HIGH certainty - definitive matches + if (byGroup.HIGH.length > 0) { + prompt += '### HIGH Certainty (Definitive - trust these)\n\n'; + if (mode === 'apply') { + prompt += '_Action: Apply fixes directly for autoFix patterns._\n\n'; + } + prompt += formatFindingsList(byGroup.HIGH); + prompt += '\n'; + } + + // MEDIUM certainty - needs context verification + if (byGroup.MEDIUM.length > 0) { + prompt += '### MEDIUM Certainty (Verify context)\n\n'; + prompt += '_Action: Review surrounding code before applying._\n\n'; + prompt += formatFindingsList(byGroup.MEDIUM); + prompt += '\n'; + } + + // LOW certainty - use judgment + if (byGroup.LOW.length > 0) { + prompt += '### LOW Certainty (Use judgment)\n\n'; + prompt += '_Action: May be false positives. Investigate before acting._\n\n'; + prompt += formatFindingsList(byGroup.LOW); + prompt += '\n'; + } + + // Action summary + prompt += '### Action Summary\n\n'; + const autoFixable = findings.filter(f => f.autoFix && f.autoFix !== 'flag' && f.autoFix !== 'none'); + const needsReview = findings.filter(f => f.autoFix === 'flag' || f.autoFix === 'none'); + + prompt += `- Auto-fixable: ${autoFixable.length}\n`; + prompt += `- Needs manual review: ${needsReview.length}\n`; + + return prompt; +} + +/** + * Format a list of findings for the prompt + * + * @param {Array} findings - Findings to format + * @returns {string} Formatted list + */ +function formatFindingsList(findings) { + // Group by file for compact output + const byFile = {}; + for (const f of findings) { + if (!byFile[f.file]) byFile[f.file] = []; + byFile[f.file].push(f); + } + + let output = ''; + for (const [file, fileFindings] of Object.entries(byFile)) { + output += `**${file}**\n`; + for (const f of fileFindings) { + const fixTag = f.autoFix && f.autoFix !== 'flag' && f.autoFix !== 'none' + ? ` [${f.autoFix}]` + : ''; + output += `- L${f.line}: ${f.description}${fixTag}\n`; + } + output += '\n'; + } + + return output; +} + +module.exports = { + runPipeline, + // Exported for testing + runPhase1, + runMultiPassAnalyzers, + runPhase2, + buildSummary, + formatHandoffPrompt, + // Constants + CERTAINTY, + THOROUGHNESS +}; diff --git a/plugins/project-review/lib/config/index.js b/plugins/project-review/lib/config/index.js new file mode 100644 index 00000000..25bffeb2 --- /dev/null +++ b/plugins/project-review/lib/config/index.js @@ -0,0 +1,14 @@ +/** + * Configuration Module + * + * Placeholder for future configuration management. + * This module exists to satisfy lib/index.js imports. + * + * @module config + * @author Avi Fenesh + * @license MIT + */ + +module.exports = { + // Configuration will be added as needed +}; diff --git a/plugins/project-review/lib/index.js b/plugins/project-review/lib/index.js index f927426e..860cb976 100644 --- a/plugins/project-review/lib/index.js +++ b/plugins/project-review/lib/index.js @@ -14,6 +14,8 @@ const detectPlatform = require('./platform/detect-platform'); const verifyTools = require('./platform/verify-tools'); const reviewPatterns = require('./patterns/review-patterns'); const slopPatterns = require('./patterns/slop-patterns'); +const pipeline = require('./patterns/pipeline'); +const cliEnhancers = require('./patterns/cli-enhancers'); const workflowState = require('./state/workflow-state'); const contextOptimizer = require('./utils/context-optimizer'); const shellEscape = require('./utils/shell-escape'); @@ -65,7 +67,32 @@ const patterns = { * Slop patterns for AI-generated code detection * @see module:patterns/slop-patterns */ - slop: slopPatterns + slop: slopPatterns, + + /** + * Slop detection pipeline orchestrator + * @see module:patterns/pipeline + */ + pipeline: { + runPipeline: pipeline.runPipeline, + CERTAINTY: pipeline.CERTAINTY, + THOROUGHNESS: pipeline.THOROUGHNESS, + formatHandoffPrompt: pipeline.formatHandoffPrompt, + buildSummary: pipeline.buildSummary + }, + + /** + * Optional CLI tool enhancers for deep analysis + * @see module:patterns/cli-enhancers + */ + cliEnhancers: { + detectAvailableTools: cliEnhancers.detectAvailableTools, + runDuplicateDetection: cliEnhancers.runDuplicateDetection, + runDependencyAnalysis: cliEnhancers.runDependencyAnalysis, + runComplexityAnalysis: cliEnhancers.runComplexityAnalysis, + getMissingToolsMessage: cliEnhancers.getMissingToolsMessage, + CLI_TOOLS: cliEnhancers.CLI_TOOLS + } }; /** @@ -161,6 +188,8 @@ module.exports = { verifyTools, reviewPatterns, slopPatterns, + pipeline, + cliEnhancers, workflowState, contextOptimizer, shellEscape, diff --git a/plugins/project-review/lib/patterns/cli-enhancers.js b/plugins/project-review/lib/patterns/cli-enhancers.js new file mode 100644 index 00000000..6cfac1da --- /dev/null +++ b/plugins/project-review/lib/patterns/cli-enhancers.js @@ -0,0 +1,602 @@ +/** + * CLI Enhancers for Slop Detection Pipeline + * + * Optional CLI tool integration for Phase 2 detection. + * All tools are user-installed globally - zero npm dependencies for this module. + * Functions gracefully degrade when tools are not available. + * + * Supported languages: javascript, typescript, python, rust, go + * + * @module patterns/cli-enhancers + * @author Avi Fenesh + * @license MIT + */ + +const { execSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const { escapeDoubleQuotes } = require('../utils/shell-escape'); + +/** + * Cache for tool availability (per-repo) + * Key: repoPath, Value: { tools: {...}, languages: [...], timestamp: Date } + */ +const toolCache = new Map(); +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Supported languages (must match slop-patterns.js) + */ +const SUPPORTED_LANGUAGES = ['javascript', 'typescript', 'python', 'rust', 'go']; + +/** + * CLI tool definitions organized by language + * Only includes tools for supported languages + */ +const CLI_TOOLS = { + // Cross-language tools + jscpd: { + name: 'jscpd', + description: 'Copy/paste detector for code duplication', + checkCommand: 'jscpd --version', + installHint: 'npm install -g jscpd', + languages: ['javascript', 'typescript', 'python', 'go', 'rust'] + }, + + // JavaScript/TypeScript tools + madge: { + name: 'madge', + description: 'Circular dependency detector', + checkCommand: 'madge --version', + installHint: 'npm install -g madge', + languages: ['javascript', 'typescript'] + }, + escomplex: { + name: 'escomplex', + description: 'Cyclomatic complexity analyzer', + checkCommand: 'escomplex --version', + installHint: 'npm install -g escomplex', + languages: ['javascript'] + }, + + // Python tools + pylint: { + name: 'pylint', + description: 'Python linter with complexity analysis', + checkCommand: 'pylint --version', + installHint: 'pip install pylint', + languages: ['python'] + }, + radon: { + name: 'radon', + description: 'Python complexity and maintainability metrics', + checkCommand: 'radon --version', + installHint: 'pip install radon', + languages: ['python'] + }, + + // Go tools + golangci_lint: { + name: 'golangci-lint', + description: 'Go linters aggregator with complexity checks', + checkCommand: 'golangci-lint --version', + installHint: 'go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest', + languages: ['go'] + }, + + // Rust tools + clippy: { + name: 'cargo-clippy', + description: 'Rust linter with code smell detection', + checkCommand: 'cargo clippy --version', + installHint: 'rustup component add clippy', + languages: ['rust'] + } +}; + +/** + * Check if a CLI tool is available in PATH + * + * @param {string} command - Command to check (e.g., 'jscpd --version') + * @returns {boolean} True if tool is available + */ +function isToolAvailable(command) { + try { + execSync(command, { + stdio: 'pipe', + timeout: 5000, + windowsHide: true + }); + return true; + } catch { + return false; + } +} + +/** + * Get cache key for a repo + * @param {string} repoPath - Repository root path + * @returns {string} Cache key + */ +function getCacheKey(repoPath) { + return path.resolve(repoPath); +} + +/** + * Check if cache is valid + * @param {Object} cacheEntry - Cache entry + * @returns {boolean} True if cache is still valid + */ +function isCacheValid(cacheEntry) { + if (!cacheEntry) return false; + return Date.now() - cacheEntry.timestamp < CACHE_TTL_MS; +} + +/** + * Clear the tool cache (useful for testing) + */ +function clearCache() { + toolCache.clear(); +} + +/** + * Detect primary language(s) of a repository based on file extensions and config files + * + * @param {string} repoPath - Repository root path + * @returns {string[]} Array of detected languages (only supported ones) + */ +function detectProjectLanguages(repoPath) { + const languages = new Set(); + + // Check for language-specific config files + const configIndicators = { + 'package.json': ['javascript', 'typescript'], + 'tsconfig.json': ['typescript'], + 'requirements.txt': ['python'], + 'setup.py': ['python'], + 'pyproject.toml': ['python'], + 'Pipfile': ['python'], + 'go.mod': ['go'], + 'go.sum': ['go'], + 'Cargo.toml': ['rust'] + }; + + for (const [file, langs] of Object.entries(configIndicators)) { + if (fs.existsSync(path.join(repoPath, file))) { + langs.forEach(l => languages.add(l)); + } + } + + // If no config files found, scan for source files + if (languages.size === 0) { + const extensionMap = { + '.js': 'javascript', + '.jsx': 'javascript', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.ts': 'typescript', + '.tsx': 'typescript', + '.py': 'python', + '.go': 'go', + '.rs': 'rust' + }; + + // Quick scan of top-level and src/ directories + const dirsToScan = [repoPath, path.join(repoPath, 'src'), path.join(repoPath, 'lib')]; + + for (const dir of dirsToScan) { + if (!fs.existsSync(dir)) continue; + try { + const files = fs.readdirSync(dir); + for (const file of files) { + const ext = path.extname(file).toLowerCase(); + if (extensionMap[ext]) { + languages.add(extensionMap[ext]); + } + } + } catch { + // Directory not readable + } + } + } + + // Filter to only supported languages + const result = Array.from(languages).filter(l => SUPPORTED_LANGUAGES.includes(l)); + + // Default to javascript if nothing detected + if (result.length === 0) { + result.push('javascript'); + } + + return result; +} + +/** + * Get tools relevant for specific languages + * + * @param {string[]} languages - Array of language names + * @returns {Object} Filtered CLI_TOOLS for the specified languages + */ +function getToolsForLanguages(languages) { + const relevant = {}; + + for (const [toolName, tool] of Object.entries(CLI_TOOLS)) { + if (tool.languages.some(lang => languages.includes(lang))) { + relevant[toolName] = tool; + } + } + + return relevant; +} + +/** + * Detect which CLI tools are available on the system + * Uses cache when available + * + * @param {string[]} [languages] - Optional languages to filter tools for + * @param {string} [repoPath] - Optional repo path for caching + * @returns {Object} Object with tool names as keys and availability as boolean values + */ +function detectAvailableTools(languages = null, repoPath = null) { + // Check cache if repoPath provided + if (repoPath) { + const cacheKey = getCacheKey(repoPath); + const cached = toolCache.get(cacheKey); + if (isCacheValid(cached)) { + // Return cached tools filtered by languages if specified + if (languages) { + const relevantTools = getToolsForLanguages(languages); + const filtered = {}; + for (const name of Object.keys(relevantTools)) { + filtered[name] = cached.tools[name] || false; + } + return filtered; + } + return { ...cached.tools }; + } + } + + // Get tools to check + const toolsToCheck = languages ? getToolsForLanguages(languages) : CLI_TOOLS; + const result = {}; + + for (const [toolName, tool] of Object.entries(toolsToCheck)) { + result[toolName] = isToolAvailable(tool.checkCommand); + } + + // Update cache if repoPath provided + if (repoPath) { + const cacheKey = getCacheKey(repoPath); + const existing = toolCache.get(cacheKey) || {}; + toolCache.set(cacheKey, { + tools: { ...existing.tools, ...result }, + languages: languages || existing.languages || [], + timestamp: Date.now() + }); + } + + return result; +} + +/** + * Get tool availability for a specific repo (with caching) + * + * @param {string} repoPath - Repository root path + * @param {Object} [options] - Options + * @param {boolean} [options.forceRefresh=false] - Force cache refresh + * @returns {{ available: Object, missing: string[], languages: string[] }} Tool availability info + */ +function getToolAvailabilityForRepo(repoPath, options = {}) { + const cacheKey = getCacheKey(repoPath); + + // Check cache unless force refresh + if (!options.forceRefresh) { + const cached = toolCache.get(cacheKey); + if (isCacheValid(cached) && cached.languages && cached.languages.length > 0) { + const relevantTools = getToolsForLanguages(cached.languages); + const missing = Object.keys(relevantTools).filter(t => !cached.tools[t]); + return { + available: { ...cached.tools }, + missing, + languages: [...cached.languages] + }; + } + } + + // Detect languages + const languages = detectProjectLanguages(repoPath); + + // Detect tools for those languages + const available = detectAvailableTools(languages, repoPath); + + // Find missing tools + const relevantTools = getToolsForLanguages(languages); + const missing = Object.keys(relevantTools).filter(t => !available[t]); + + // Update cache + toolCache.set(cacheKey, { + tools: available, + languages, + timestamp: Date.now() + }); + + return { available, missing, languages }; +} + +/** + * Run duplicate code detection using jscpd + * + * @param {string} repoPath - Repository root path + * @param {Object} options - Options + * @param {number} [options.minLines=5] - Minimum lines for duplicate detection + * @param {number} [options.minTokens=50] - Minimum tokens for duplicate detection + * @returns {Array|null} Duplicates found, or null if tool not available + */ +function runDuplicateDetection(repoPath, options = {}) { + if (!isToolAvailable(CLI_TOOLS.jscpd.checkCommand)) { + return null; + } + + const minLines = options.minLines || 5; + const minTokens = options.minTokens || 50; + + try { + // Run jscpd with JSON output + // Escape repoPath to prevent command injection + const outputPath = process.platform === 'win32' ? 'NUL' : '/dev/null'; + const safeRepoPath = escapeDoubleQuotes(repoPath); + const command = `jscpd "${safeRepoPath}" --min-lines ${minLines} --min-tokens ${minTokens} --reporters json --output ${outputPath} --silent 2>&1`; + + const result = execSync(command, { + stdio: 'pipe', + timeout: 60000, + windowsHide: true, + cwd: repoPath, + encoding: 'utf8' + }); + + // Parse JSON output + try { + const report = JSON.parse(result); + const duplicates = []; + + if (report.duplicates) { + for (const dup of report.duplicates) { + duplicates.push({ + firstFile: dup.firstFile?.name || 'unknown', + firstLine: dup.firstFile?.start || 0, + secondFile: dup.secondFile?.name || 'unknown', + secondLine: dup.secondFile?.start || 0, + lines: dup.lines || 0, + tokens: dup.tokens || 0, + fragment: dup.fragment?.substring(0, 100) || '' + }); + } + } + + return duplicates; + } catch { + // JSON parsing failed, return empty array + return []; + } + } catch { + // Tool execution failed + return null; + } +} + +/** + * Run circular dependency detection using madge + * + * @param {string} repoPath - Repository root path + * @param {Object} options - Options + * @param {string} [options.entry] - Entry file (defaults to src/index.js or index.js) + * @returns {Array|null} Circular dependency cycles, or null if tool not available + */ +function runDependencyAnalysis(repoPath, options = {}) { + if (!isToolAvailable(CLI_TOOLS.madge.checkCommand)) { + return null; + } + + // Determine entry point + let entry = options.entry; + if (!entry) { + const possibleEntries = [ + 'src/index.js', + 'src/index.ts', + 'index.js', + 'index.ts', + 'lib/index.js', + 'main.js' + ]; + + for (const e of possibleEntries) { + if (fs.existsSync(path.join(repoPath, e))) { + entry = e; + break; + } + } + } + + if (!entry) { + // No entry point found, scan entire directory + entry = '.'; + } + + try { + // Run madge with circular flag and JSON output + // Escape entry path to prevent command injection + const safeEntry = escapeDoubleQuotes(entry); + const command = `madge --circular --json "${safeEntry}"`; + + const result = execSync(command, { + stdio: 'pipe', + timeout: 60000, + windowsHide: true, + cwd: repoPath, + encoding: 'utf8' + }); + + // Parse JSON output + try { + const cycles = JSON.parse(result); + // madge returns array of arrays (each cycle is an array of file paths) + return Array.isArray(cycles) ? cycles : []; + } catch { + return []; + } + } catch { + // Tool execution failed + return null; + } +} + +/** + * Run complexity analysis using escomplex + * + * @param {string} repoPath - Repository root path + * @param {string[]} targetFiles - Files to analyze + * @param {Object} options - Options + * @returns {Array|null} Complexity results, or null if tool not available + */ +function runComplexityAnalysis(repoPath, targetFiles, options = {}) { + if (!isToolAvailable(CLI_TOOLS.escomplex.checkCommand)) { + return null; + } + + const results = []; + + // escomplex works on individual files + for (const file of targetFiles) { + // Only analyze JS/TS files + if (!file.match(/\.[jt]sx?$/)) continue; + + const filePath = path.isAbsolute(file) ? file : path.join(repoPath, file); + + try { + // Escape file path to prevent command injection + const safeFilePath = escapeDoubleQuotes(filePath); + const command = `escomplex "${safeFilePath}" --format json`; + + const result = execSync(command, { + stdio: 'pipe', + timeout: 30000, + windowsHide: true, + cwd: repoPath, + encoding: 'utf8' + }); + + try { + const report = JSON.parse(result); + + // Extract function-level complexity + if (report.functions) { + for (const fn of report.functions) { + results.push({ + file, + name: fn.name || 'anonymous', + line: fn.line || 0, + complexity: fn.cyclomatic || 0, + halstead: fn.halstead?.difficulty || 0, + sloc: fn.sloc?.logical || 0 + }); + } + } + + // Also include module-level metrics + if (report.aggregate) { + results.push({ + file, + name: 'module', + line: 0, + complexity: report.aggregate.cyclomatic || 0, + halstead: report.aggregate.halstead?.difficulty || 0, + sloc: report.aggregate.sloc?.logical || 0, + maintainability: report.maintainability || 0 + }); + } + } catch { + // JSON parsing failed for this file + } + } catch { + // Tool execution failed for this file + } + } + + return results.length > 0 ? results : null; +} + +/** + * Get user-friendly message about missing tools (language-aware) + * + * @param {string[]} missingTools - Array of missing tool names + * @param {string[]} [languages] - Detected languages (for context in message) + * @returns {string} Formatted message + */ +function getMissingToolsMessage(missingTools, languages = null) { + if (!missingTools || missingTools.length === 0) { + return ''; + } + + // Filter to only known tools + const validTools = missingTools.filter(t => CLI_TOOLS[t]); + if (validTools.length === 0) { + return ''; + } + + let message = '\n## Enhanced Analysis Available\n\n'; + + if (languages && languages.length > 0) { + message += `Detected project languages: ${languages.join(', ')}\n\n`; + } + + message += 'For deeper analysis, consider installing:\n\n'; + + for (const toolName of validTools) { + const tool = CLI_TOOLS[toolName]; + if (tool) { + message += `- **${tool.name}**: ${tool.description}\n`; + message += ` Install: \`${tool.installHint}\`\n`; + } + } + + message += '\nThese tools are optional and enhance detection capabilities.\n'; + + return message; +} + +/** + * Get all CLI tool definitions + * + * @returns {Object} CLI tool definitions + */ +function getToolDefinitions() { + return { ...CLI_TOOLS }; +} + +/** + * Get supported languages list + * + * @returns {string[]} Array of supported language names + */ +function getSupportedLanguages() { + return [...SUPPORTED_LANGUAGES]; +} + +module.exports = { + detectAvailableTools, + detectProjectLanguages, + getToolsForLanguages, + getToolAvailabilityForRepo, + runDuplicateDetection, + runDependencyAnalysis, + runComplexityAnalysis, + getMissingToolsMessage, + getToolDefinitions, + getSupportedLanguages, + clearCache, + // Exported for testing + isToolAvailable, + CLI_TOOLS, + SUPPORTED_LANGUAGES +}; diff --git a/plugins/project-review/lib/patterns/pipeline.js b/plugins/project-review/lib/patterns/pipeline.js new file mode 100644 index 00000000..1b630ad7 --- /dev/null +++ b/plugins/project-review/lib/patterns/pipeline.js @@ -0,0 +1,553 @@ +/** + * Slop Detection Pipeline + * + * 3-phase detection pipeline orchestrator: + * - Phase 1 (built-in): regex patterns + multi-pass analyzers - always runs + * - Phase 2 (optional): CLI tools (jscpd, madge, escomplex) - if available + * - Phase 3 (LLM handoff): certainty-tagged findings for agent review + * + * Inherits modes from deslop-around: report (analyze only) vs apply (fix issues) + * + * @module patterns/pipeline + * @author Avi Fenesh + * @license MIT + */ + +const path = require('path'); +const fs = require('fs'); +const slopPatterns = require('./slop-patterns'); +const analyzers = require('./slop-analyzers'); + +/** + * Certainty levels for findings + * HIGH: Single regex match - definitive + * MEDIUM: Multi-pass analysis - requires context + * LOW: Heuristic/CLI tool - needs verification + */ +const CERTAINTY = { + HIGH: 'HIGH', + MEDIUM: 'MEDIUM', + LOW: 'LOW' +}; + +/** + * Thoroughness levels + * quick: Phase 1 regex only - fastest + * normal: Phase 1 + multi-pass analyzers - balanced + * deep: Phase 1 + Phase 2 CLI tools (if available) - thorough + */ +const THOROUGHNESS = { + QUICK: 'quick', + NORMAL: 'normal', + DEEP: 'deep' +}; + +/** + * Run the slop detection pipeline + * + * @param {string} repoPath - Repository root path + * @param {Object} options - Pipeline options + * @param {string} [options.thoroughness='normal'] - quick | normal | deep + * @param {string[]} [options.targetFiles] - Specific files to analyze (defaults to all source files) + * @param {string} [options.language] - Filter to specific language + * @param {string} [options.mode='report'] - report | apply + * @param {Object} [options.cliTools] - Pre-detected CLI tools (from detectAvailableTools) + * @returns {Object} Pipeline results: { findings, summary, phase3Prompt, missingTools } + */ +function runPipeline(repoPath, options = {}) { + const thoroughness = options.thoroughness || THOROUGHNESS.NORMAL; + const mode = options.mode || 'report'; + const language = options.language || null; + + const findings = []; + const missingTools = []; + let cliTools = options.cliTools || null; + + // Get target files + let targetFiles = options.targetFiles; + if (!targetFiles || targetFiles.length === 0) { + const result = analyzers.countSourceFiles(repoPath, { + maxFiles: 1000, + includeTests: false + }); + targetFiles = result.files; + } + + // Phase 1: Built-in regex patterns (always runs) + const phase1Results = runPhase1(repoPath, targetFiles, language); + findings.push(...phase1Results); + + // Phase 1b: Multi-pass analyzers (if normal or deep) + if (thoroughness !== THOROUGHNESS.QUICK) { + const multiPassResults = runMultiPassAnalyzers(repoPath, targetFiles); + findings.push(...multiPassResults); + } + + // Phase 2: CLI tools (only if deep and tools available) + if (thoroughness === THOROUGHNESS.DEEP) { + // Lazy-load CLI enhancers to avoid circular dependencies + const cliEnhancers = require('./cli-enhancers'); + + if (!cliTools) { + cliTools = cliEnhancers.detectAvailableTools(); + } + + // Track missing tools for user notification + if (!cliTools.jscpd) missingTools.push('jscpd'); + if (!cliTools.madge) missingTools.push('madge'); + if (!cliTools.escomplex) missingTools.push('escomplex'); + + const phase2Results = runPhase2(repoPath, cliTools, targetFiles); + findings.push(...phase2Results); + } + + // Build summary + const summary = buildSummary(findings); + + // Generate Phase 3 handoff prompt + const phase3Prompt = formatHandoffPrompt(findings, mode); + + return { + findings, + summary, + phase3Prompt, + missingTools, + metadata: { + repoPath, + thoroughness, + mode, + filesAnalyzed: targetFiles.length, + timestamp: new Date().toISOString() + } + }; +} + +/** + * Phase 1: Run built-in regex patterns against target files + * + * @param {string} repoPath - Repository root + * @param {string[]} targetFiles - Files to analyze + * @param {string|null} language - Optional language filter + * @returns {Array} Findings with HIGH certainty + */ +function runPhase1(repoPath, targetFiles, language) { + const findings = []; + + // Get patterns (filtered by language if specified) + const patterns = language + ? slopPatterns.getPatternsForLanguage(language) + : slopPatterns.slopPatterns; + + for (const file of targetFiles) { + // Skip if language filter doesn't match file extension + if (language) { + const fileLanguage = analyzers.detectLanguage(file); + if (fileLanguage !== language && fileLanguage !== 'js') continue; + } + + const filePath = path.isAbsolute(file) ? file : path.join(repoPath, file); + + let content; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch { + continue; // Skip unreadable files + } + + const lines = content.split('\n'); + + for (const [patternName, pattern] of Object.entries(patterns)) { + // Skip multi-pass patterns (handled separately) + if (pattern.requiresMultiPass) continue; + + // Skip if no regex pattern + if (!pattern.pattern) continue; + + // Skip if file matches exclude patterns + if (slopPatterns.isFileExcluded(file, pattern.exclude)) continue; + + // Check each line + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (pattern.pattern.test(line)) { + findings.push({ + file, + line: i + 1, + patternName, + severity: pattern.severity, + certainty: CERTAINTY.HIGH, + description: pattern.description, + autoFix: pattern.autoFix, + content: line.trim().substring(0, 100), + phase: 1 + }); + } + } + } + } + + return findings; +} + +/** + * Run multi-pass analyzers (doc/code ratio, verbosity, etc.) + * + * @param {string} repoPath - Repository root + * @param {string[]} targetFiles - Files to analyze + * @returns {Array} Findings with MEDIUM certainty + */ +function runMultiPassAnalyzers(repoPath, targetFiles) { + const findings = []; + + // Get multi-pass pattern definitions for thresholds + const multiPassPatterns = slopPatterns.getMultiPassPatterns(); + + for (const file of targetFiles) { + const filePath = path.isAbsolute(file) ? file : path.join(repoPath, file); + const lang = analyzers.detectLanguage(file); + + // Skip non-JS files for doc/code ratio (JSDoc specific) + if (lang !== 'js') continue; + + let content; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch { + continue; + } + + // Doc/code ratio analysis + const docCodePattern = multiPassPatterns.doc_code_ratio_js; + if (docCodePattern) { + const docRatioViolations = analyzers.analyzeDocCodeRatio(content, { + minFunctionLines: docCodePattern.minFunctionLines || 3, + maxRatio: docCodePattern.maxRatio || 3.0 + }); + + for (const v of docRatioViolations) { + findings.push({ + file, + line: v.line, + patternName: 'doc_code_ratio_js', + severity: docCodePattern.severity, + certainty: CERTAINTY.MEDIUM, + description: `${docCodePattern.description} (${v.docLines} doc lines / ${v.codeLines} code lines = ${v.ratio}x)`, + autoFix: docCodePattern.autoFix, + content: `Function at line ${v.line}`, + phase: 1, + details: { docLines: v.docLines, codeLines: v.codeLines, ratio: v.ratio } + }); + } + } + + // Verbosity ratio analysis + const verbosityPattern = multiPassPatterns.verbosity_ratio; + if (verbosityPattern) { + const verbosityViolations = analyzers.analyzeVerbosityRatio(content, { + minCodeLines: verbosityPattern.minCodeLines || 3, + maxCommentRatio: verbosityPattern.maxCommentRatio || 2.0, + filePath: file + }); + + for (const v of verbosityViolations) { + findings.push({ + file, + line: v.line, + patternName: 'verbosity_ratio', + severity: verbosityPattern.severity, + certainty: CERTAINTY.MEDIUM, + description: `${verbosityPattern.description} (${v.commentLines} comment lines / ${v.codeLines} code lines = ${v.ratio}x)`, + autoFix: verbosityPattern.autoFix, + content: `Function at line ${v.line}`, + phase: 1, + details: { commentLines: v.commentLines, codeLines: v.codeLines, ratio: v.ratio } + }); + } + } + } + + // Project-level analyzers (run once, not per-file) + const overEngPattern = multiPassPatterns.over_engineering_metrics; + if (overEngPattern) { + const overEngResult = analyzers.analyzeOverEngineering(repoPath, { + fileRatioThreshold: overEngPattern.fileRatioThreshold || 20, + linesPerExportThreshold: overEngPattern.linesPerExportThreshold || 500, + depthThreshold: overEngPattern.depthThreshold || 4 + }); + + for (const v of overEngResult.violations) { + findings.push({ + file: 'project-level', + line: 0, + patternName: 'over_engineering_metrics', + severity: v.severity, + certainty: CERTAINTY.MEDIUM, + description: `Over-engineering: ${v.type} - ${v.value} (threshold: ${v.threshold})`, + autoFix: 'flag', + content: v.value, + phase: 1, + details: v.details + }); + } + } + + // Buzzword inflation analysis + const buzzwordPattern = multiPassPatterns.buzzword_inflation; + if (buzzwordPattern) { + const buzzwordResult = analyzers.analyzeBuzzwordInflation(repoPath, { + minEvidenceMatches: buzzwordPattern.minEvidenceMatches || 2 + }); + + for (const v of buzzwordResult.violations) { + findings.push({ + file: v.file, + line: v.line, + patternName: 'buzzword_inflation', + severity: v.severity, + certainty: CERTAINTY.MEDIUM, + description: v.message, + autoFix: 'flag', + content: v.claim, + phase: 1, + details: { buzzword: v.buzzword, category: v.category, evidenceCount: v.evidenceCount } + }); + } + } + + // Infrastructure without implementation + const infraPattern = multiPassPatterns.infrastructure_without_implementation; + if (infraPattern) { + const infraResult = analyzers.analyzeInfrastructureWithoutImplementation(repoPath); + + for (const v of infraResult.violations) { + findings.push({ + file: v.file, + line: v.line, + patternName: 'infrastructure_without_implementation', + severity: v.severity, + certainty: CERTAINTY.MEDIUM, + description: v.message, + autoFix: 'flag', + content: v.content, + phase: 1, + details: { varName: v.varName, type: v.type } + }); + } + } + + return findings; +} + +/** + * Phase 2: Run CLI tools (if available) + * + * @param {string} repoPath - Repository root + * @param {Object} cliTools - Available CLI tools { jscpd, madge, escomplex } + * @param {string[]} targetFiles - Files to analyze + * @returns {Array} Findings with LOW certainty + */ +function runPhase2(repoPath, cliTools, targetFiles) { + const findings = []; + const cliEnhancers = require('./cli-enhancers'); + + // Duplicate detection with jscpd + if (cliTools.jscpd) { + const duplicates = cliEnhancers.runDuplicateDetection(repoPath); + if (duplicates) { + for (const dup of duplicates) { + findings.push({ + file: dup.firstFile, + line: dup.firstLine, + patternName: 'code_duplication', + severity: 'medium', + certainty: CERTAINTY.LOW, + description: `Code duplication: ${dup.lines} lines duplicated in ${dup.secondFile}:${dup.secondLine}`, + autoFix: 'flag', + content: `${dup.lines} lines duplicated`, + phase: 2, + details: dup + }); + } + } + } + + // Circular dependencies with madge + if (cliTools.madge) { + const circularDeps = cliEnhancers.runDependencyAnalysis(repoPath); + if (circularDeps) { + for (const cycle of circularDeps) { + findings.push({ + file: cycle[0], + line: 0, + patternName: 'circular_dependency', + severity: 'high', + certainty: CERTAINTY.LOW, + description: `Circular dependency: ${cycle.join(' -> ')}`, + autoFix: 'flag', + content: cycle.join(' -> '), + phase: 2, + details: { cycle } + }); + } + } + } + + // Complexity analysis with escomplex + if (cliTools.escomplex) { + const complexityResults = cliEnhancers.runComplexityAnalysis(repoPath, targetFiles); + if (complexityResults) { + for (const result of complexityResults) { + if (result.complexity > 10) { // High cyclomatic complexity threshold + findings.push({ + file: result.file, + line: result.line || 0, + patternName: 'high_complexity', + severity: result.complexity > 20 ? 'high' : 'medium', + certainty: CERTAINTY.LOW, + description: `High cyclomatic complexity: ${result.complexity} in ${result.name}`, + autoFix: 'flag', + content: `${result.name}: complexity ${result.complexity}`, + phase: 2, + details: result + }); + } + } + } + } + + return findings; +} + +/** + * Build summary statistics from findings + * + * @param {Array} findings - All findings + * @returns {Object} Summary statistics + */ +function buildSummary(findings) { + const summary = { + total: findings.length, + bySeverity: { critical: 0, high: 0, medium: 0, low: 0 }, + byCertainty: { HIGH: 0, MEDIUM: 0, LOW: 0 }, + byPhase: { 1: 0, 2: 0 }, + byAutoFix: { remove: 0, replace: 0, add_logging: 0, flag: 0, none: 0 }, + topPatterns: {} + }; + + for (const f of findings) { + summary.bySeverity[f.severity] = (summary.bySeverity[f.severity] || 0) + 1; + summary.byCertainty[f.certainty] = (summary.byCertainty[f.certainty] || 0) + 1; + summary.byPhase[f.phase] = (summary.byPhase[f.phase] || 0) + 1; + summary.byAutoFix[f.autoFix] = (summary.byAutoFix[f.autoFix] || 0) + 1; + summary.topPatterns[f.patternName] = (summary.topPatterns[f.patternName] || 0) + 1; + } + + return summary; +} + +/** + * Format handoff prompt for LLM (Phase 3) + * + * Creates a token-efficient prompt for the agent to review findings. + * Groups by certainty level with action guidance: + * - HIGH: Apply directly (if apply mode) + * - MEDIUM: Verify context before applying + * - LOW: Use judgment, may be false positive + * + * @param {Array} findings - All findings + * @param {string} mode - report | apply + * @returns {string} Formatted prompt + */ +function formatHandoffPrompt(findings, mode) { + if (findings.length === 0) { + return '## Slop Detection Results\n\nNo issues detected.'; + } + + // Group findings by certainty + const byGroup = { + HIGH: findings.filter(f => f.certainty === CERTAINTY.HIGH), + MEDIUM: findings.filter(f => f.certainty === CERTAINTY.MEDIUM), + LOW: findings.filter(f => f.certainty === CERTAINTY.LOW) + }; + + let prompt = '## Slop Detection Results\n\n'; + prompt += `Mode: **${mode}** | Total: ${findings.length} findings\n\n`; + + // HIGH certainty - definitive matches + if (byGroup.HIGH.length > 0) { + prompt += '### HIGH Certainty (Definitive - trust these)\n\n'; + if (mode === 'apply') { + prompt += '_Action: Apply fixes directly for autoFix patterns._\n\n'; + } + prompt += formatFindingsList(byGroup.HIGH); + prompt += '\n'; + } + + // MEDIUM certainty - needs context verification + if (byGroup.MEDIUM.length > 0) { + prompt += '### MEDIUM Certainty (Verify context)\n\n'; + prompt += '_Action: Review surrounding code before applying._\n\n'; + prompt += formatFindingsList(byGroup.MEDIUM); + prompt += '\n'; + } + + // LOW certainty - use judgment + if (byGroup.LOW.length > 0) { + prompt += '### LOW Certainty (Use judgment)\n\n'; + prompt += '_Action: May be false positives. Investigate before acting._\n\n'; + prompt += formatFindingsList(byGroup.LOW); + prompt += '\n'; + } + + // Action summary + prompt += '### Action Summary\n\n'; + const autoFixable = findings.filter(f => f.autoFix && f.autoFix !== 'flag' && f.autoFix !== 'none'); + const needsReview = findings.filter(f => f.autoFix === 'flag' || f.autoFix === 'none'); + + prompt += `- Auto-fixable: ${autoFixable.length}\n`; + prompt += `- Needs manual review: ${needsReview.length}\n`; + + return prompt; +} + +/** + * Format a list of findings for the prompt + * + * @param {Array} findings - Findings to format + * @returns {string} Formatted list + */ +function formatFindingsList(findings) { + // Group by file for compact output + const byFile = {}; + for (const f of findings) { + if (!byFile[f.file]) byFile[f.file] = []; + byFile[f.file].push(f); + } + + let output = ''; + for (const [file, fileFindings] of Object.entries(byFile)) { + output += `**${file}**\n`; + for (const f of fileFindings) { + const fixTag = f.autoFix && f.autoFix !== 'flag' && f.autoFix !== 'none' + ? ` [${f.autoFix}]` + : ''; + output += `- L${f.line}: ${f.description}${fixTag}\n`; + } + output += '\n'; + } + + return output; +} + +module.exports = { + runPipeline, + // Exported for testing + runPhase1, + runMultiPassAnalyzers, + runPhase2, + buildSummary, + formatHandoffPrompt, + // Constants + CERTAINTY, + THOROUGHNESS +}; diff --git a/plugins/reality-check/lib/config/index.js b/plugins/reality-check/lib/config/index.js new file mode 100644 index 00000000..25bffeb2 --- /dev/null +++ b/plugins/reality-check/lib/config/index.js @@ -0,0 +1,14 @@ +/** + * Configuration Module + * + * Placeholder for future configuration management. + * This module exists to satisfy lib/index.js imports. + * + * @module config + * @author Avi Fenesh + * @license MIT + */ + +module.exports = { + // Configuration will be added as needed +}; diff --git a/plugins/reality-check/lib/index.js b/plugins/reality-check/lib/index.js index f927426e..860cb976 100644 --- a/plugins/reality-check/lib/index.js +++ b/plugins/reality-check/lib/index.js @@ -14,6 +14,8 @@ const detectPlatform = require('./platform/detect-platform'); const verifyTools = require('./platform/verify-tools'); const reviewPatterns = require('./patterns/review-patterns'); const slopPatterns = require('./patterns/slop-patterns'); +const pipeline = require('./patterns/pipeline'); +const cliEnhancers = require('./patterns/cli-enhancers'); const workflowState = require('./state/workflow-state'); const contextOptimizer = require('./utils/context-optimizer'); const shellEscape = require('./utils/shell-escape'); @@ -65,7 +67,32 @@ const patterns = { * Slop patterns for AI-generated code detection * @see module:patterns/slop-patterns */ - slop: slopPatterns + slop: slopPatterns, + + /** + * Slop detection pipeline orchestrator + * @see module:patterns/pipeline + */ + pipeline: { + runPipeline: pipeline.runPipeline, + CERTAINTY: pipeline.CERTAINTY, + THOROUGHNESS: pipeline.THOROUGHNESS, + formatHandoffPrompt: pipeline.formatHandoffPrompt, + buildSummary: pipeline.buildSummary + }, + + /** + * Optional CLI tool enhancers for deep analysis + * @see module:patterns/cli-enhancers + */ + cliEnhancers: { + detectAvailableTools: cliEnhancers.detectAvailableTools, + runDuplicateDetection: cliEnhancers.runDuplicateDetection, + runDependencyAnalysis: cliEnhancers.runDependencyAnalysis, + runComplexityAnalysis: cliEnhancers.runComplexityAnalysis, + getMissingToolsMessage: cliEnhancers.getMissingToolsMessage, + CLI_TOOLS: cliEnhancers.CLI_TOOLS + } }; /** @@ -161,6 +188,8 @@ module.exports = { verifyTools, reviewPatterns, slopPatterns, + pipeline, + cliEnhancers, workflowState, contextOptimizer, shellEscape, diff --git a/plugins/reality-check/lib/patterns/cli-enhancers.js b/plugins/reality-check/lib/patterns/cli-enhancers.js new file mode 100644 index 00000000..6cfac1da --- /dev/null +++ b/plugins/reality-check/lib/patterns/cli-enhancers.js @@ -0,0 +1,602 @@ +/** + * CLI Enhancers for Slop Detection Pipeline + * + * Optional CLI tool integration for Phase 2 detection. + * All tools are user-installed globally - zero npm dependencies for this module. + * Functions gracefully degrade when tools are not available. + * + * Supported languages: javascript, typescript, python, rust, go + * + * @module patterns/cli-enhancers + * @author Avi Fenesh + * @license MIT + */ + +const { execSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const { escapeDoubleQuotes } = require('../utils/shell-escape'); + +/** + * Cache for tool availability (per-repo) + * Key: repoPath, Value: { tools: {...}, languages: [...], timestamp: Date } + */ +const toolCache = new Map(); +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Supported languages (must match slop-patterns.js) + */ +const SUPPORTED_LANGUAGES = ['javascript', 'typescript', 'python', 'rust', 'go']; + +/** + * CLI tool definitions organized by language + * Only includes tools for supported languages + */ +const CLI_TOOLS = { + // Cross-language tools + jscpd: { + name: 'jscpd', + description: 'Copy/paste detector for code duplication', + checkCommand: 'jscpd --version', + installHint: 'npm install -g jscpd', + languages: ['javascript', 'typescript', 'python', 'go', 'rust'] + }, + + // JavaScript/TypeScript tools + madge: { + name: 'madge', + description: 'Circular dependency detector', + checkCommand: 'madge --version', + installHint: 'npm install -g madge', + languages: ['javascript', 'typescript'] + }, + escomplex: { + name: 'escomplex', + description: 'Cyclomatic complexity analyzer', + checkCommand: 'escomplex --version', + installHint: 'npm install -g escomplex', + languages: ['javascript'] + }, + + // Python tools + pylint: { + name: 'pylint', + description: 'Python linter with complexity analysis', + checkCommand: 'pylint --version', + installHint: 'pip install pylint', + languages: ['python'] + }, + radon: { + name: 'radon', + description: 'Python complexity and maintainability metrics', + checkCommand: 'radon --version', + installHint: 'pip install radon', + languages: ['python'] + }, + + // Go tools + golangci_lint: { + name: 'golangci-lint', + description: 'Go linters aggregator with complexity checks', + checkCommand: 'golangci-lint --version', + installHint: 'go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest', + languages: ['go'] + }, + + // Rust tools + clippy: { + name: 'cargo-clippy', + description: 'Rust linter with code smell detection', + checkCommand: 'cargo clippy --version', + installHint: 'rustup component add clippy', + languages: ['rust'] + } +}; + +/** + * Check if a CLI tool is available in PATH + * + * @param {string} command - Command to check (e.g., 'jscpd --version') + * @returns {boolean} True if tool is available + */ +function isToolAvailable(command) { + try { + execSync(command, { + stdio: 'pipe', + timeout: 5000, + windowsHide: true + }); + return true; + } catch { + return false; + } +} + +/** + * Get cache key for a repo + * @param {string} repoPath - Repository root path + * @returns {string} Cache key + */ +function getCacheKey(repoPath) { + return path.resolve(repoPath); +} + +/** + * Check if cache is valid + * @param {Object} cacheEntry - Cache entry + * @returns {boolean} True if cache is still valid + */ +function isCacheValid(cacheEntry) { + if (!cacheEntry) return false; + return Date.now() - cacheEntry.timestamp < CACHE_TTL_MS; +} + +/** + * Clear the tool cache (useful for testing) + */ +function clearCache() { + toolCache.clear(); +} + +/** + * Detect primary language(s) of a repository based on file extensions and config files + * + * @param {string} repoPath - Repository root path + * @returns {string[]} Array of detected languages (only supported ones) + */ +function detectProjectLanguages(repoPath) { + const languages = new Set(); + + // Check for language-specific config files + const configIndicators = { + 'package.json': ['javascript', 'typescript'], + 'tsconfig.json': ['typescript'], + 'requirements.txt': ['python'], + 'setup.py': ['python'], + 'pyproject.toml': ['python'], + 'Pipfile': ['python'], + 'go.mod': ['go'], + 'go.sum': ['go'], + 'Cargo.toml': ['rust'] + }; + + for (const [file, langs] of Object.entries(configIndicators)) { + if (fs.existsSync(path.join(repoPath, file))) { + langs.forEach(l => languages.add(l)); + } + } + + // If no config files found, scan for source files + if (languages.size === 0) { + const extensionMap = { + '.js': 'javascript', + '.jsx': 'javascript', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.ts': 'typescript', + '.tsx': 'typescript', + '.py': 'python', + '.go': 'go', + '.rs': 'rust' + }; + + // Quick scan of top-level and src/ directories + const dirsToScan = [repoPath, path.join(repoPath, 'src'), path.join(repoPath, 'lib')]; + + for (const dir of dirsToScan) { + if (!fs.existsSync(dir)) continue; + try { + const files = fs.readdirSync(dir); + for (const file of files) { + const ext = path.extname(file).toLowerCase(); + if (extensionMap[ext]) { + languages.add(extensionMap[ext]); + } + } + } catch { + // Directory not readable + } + } + } + + // Filter to only supported languages + const result = Array.from(languages).filter(l => SUPPORTED_LANGUAGES.includes(l)); + + // Default to javascript if nothing detected + if (result.length === 0) { + result.push('javascript'); + } + + return result; +} + +/** + * Get tools relevant for specific languages + * + * @param {string[]} languages - Array of language names + * @returns {Object} Filtered CLI_TOOLS for the specified languages + */ +function getToolsForLanguages(languages) { + const relevant = {}; + + for (const [toolName, tool] of Object.entries(CLI_TOOLS)) { + if (tool.languages.some(lang => languages.includes(lang))) { + relevant[toolName] = tool; + } + } + + return relevant; +} + +/** + * Detect which CLI tools are available on the system + * Uses cache when available + * + * @param {string[]} [languages] - Optional languages to filter tools for + * @param {string} [repoPath] - Optional repo path for caching + * @returns {Object} Object with tool names as keys and availability as boolean values + */ +function detectAvailableTools(languages = null, repoPath = null) { + // Check cache if repoPath provided + if (repoPath) { + const cacheKey = getCacheKey(repoPath); + const cached = toolCache.get(cacheKey); + if (isCacheValid(cached)) { + // Return cached tools filtered by languages if specified + if (languages) { + const relevantTools = getToolsForLanguages(languages); + const filtered = {}; + for (const name of Object.keys(relevantTools)) { + filtered[name] = cached.tools[name] || false; + } + return filtered; + } + return { ...cached.tools }; + } + } + + // Get tools to check + const toolsToCheck = languages ? getToolsForLanguages(languages) : CLI_TOOLS; + const result = {}; + + for (const [toolName, tool] of Object.entries(toolsToCheck)) { + result[toolName] = isToolAvailable(tool.checkCommand); + } + + // Update cache if repoPath provided + if (repoPath) { + const cacheKey = getCacheKey(repoPath); + const existing = toolCache.get(cacheKey) || {}; + toolCache.set(cacheKey, { + tools: { ...existing.tools, ...result }, + languages: languages || existing.languages || [], + timestamp: Date.now() + }); + } + + return result; +} + +/** + * Get tool availability for a specific repo (with caching) + * + * @param {string} repoPath - Repository root path + * @param {Object} [options] - Options + * @param {boolean} [options.forceRefresh=false] - Force cache refresh + * @returns {{ available: Object, missing: string[], languages: string[] }} Tool availability info + */ +function getToolAvailabilityForRepo(repoPath, options = {}) { + const cacheKey = getCacheKey(repoPath); + + // Check cache unless force refresh + if (!options.forceRefresh) { + const cached = toolCache.get(cacheKey); + if (isCacheValid(cached) && cached.languages && cached.languages.length > 0) { + const relevantTools = getToolsForLanguages(cached.languages); + const missing = Object.keys(relevantTools).filter(t => !cached.tools[t]); + return { + available: { ...cached.tools }, + missing, + languages: [...cached.languages] + }; + } + } + + // Detect languages + const languages = detectProjectLanguages(repoPath); + + // Detect tools for those languages + const available = detectAvailableTools(languages, repoPath); + + // Find missing tools + const relevantTools = getToolsForLanguages(languages); + const missing = Object.keys(relevantTools).filter(t => !available[t]); + + // Update cache + toolCache.set(cacheKey, { + tools: available, + languages, + timestamp: Date.now() + }); + + return { available, missing, languages }; +} + +/** + * Run duplicate code detection using jscpd + * + * @param {string} repoPath - Repository root path + * @param {Object} options - Options + * @param {number} [options.minLines=5] - Minimum lines for duplicate detection + * @param {number} [options.minTokens=50] - Minimum tokens for duplicate detection + * @returns {Array|null} Duplicates found, or null if tool not available + */ +function runDuplicateDetection(repoPath, options = {}) { + if (!isToolAvailable(CLI_TOOLS.jscpd.checkCommand)) { + return null; + } + + const minLines = options.minLines || 5; + const minTokens = options.minTokens || 50; + + try { + // Run jscpd with JSON output + // Escape repoPath to prevent command injection + const outputPath = process.platform === 'win32' ? 'NUL' : '/dev/null'; + const safeRepoPath = escapeDoubleQuotes(repoPath); + const command = `jscpd "${safeRepoPath}" --min-lines ${minLines} --min-tokens ${minTokens} --reporters json --output ${outputPath} --silent 2>&1`; + + const result = execSync(command, { + stdio: 'pipe', + timeout: 60000, + windowsHide: true, + cwd: repoPath, + encoding: 'utf8' + }); + + // Parse JSON output + try { + const report = JSON.parse(result); + const duplicates = []; + + if (report.duplicates) { + for (const dup of report.duplicates) { + duplicates.push({ + firstFile: dup.firstFile?.name || 'unknown', + firstLine: dup.firstFile?.start || 0, + secondFile: dup.secondFile?.name || 'unknown', + secondLine: dup.secondFile?.start || 0, + lines: dup.lines || 0, + tokens: dup.tokens || 0, + fragment: dup.fragment?.substring(0, 100) || '' + }); + } + } + + return duplicates; + } catch { + // JSON parsing failed, return empty array + return []; + } + } catch { + // Tool execution failed + return null; + } +} + +/** + * Run circular dependency detection using madge + * + * @param {string} repoPath - Repository root path + * @param {Object} options - Options + * @param {string} [options.entry] - Entry file (defaults to src/index.js or index.js) + * @returns {Array|null} Circular dependency cycles, or null if tool not available + */ +function runDependencyAnalysis(repoPath, options = {}) { + if (!isToolAvailable(CLI_TOOLS.madge.checkCommand)) { + return null; + } + + // Determine entry point + let entry = options.entry; + if (!entry) { + const possibleEntries = [ + 'src/index.js', + 'src/index.ts', + 'index.js', + 'index.ts', + 'lib/index.js', + 'main.js' + ]; + + for (const e of possibleEntries) { + if (fs.existsSync(path.join(repoPath, e))) { + entry = e; + break; + } + } + } + + if (!entry) { + // No entry point found, scan entire directory + entry = '.'; + } + + try { + // Run madge with circular flag and JSON output + // Escape entry path to prevent command injection + const safeEntry = escapeDoubleQuotes(entry); + const command = `madge --circular --json "${safeEntry}"`; + + const result = execSync(command, { + stdio: 'pipe', + timeout: 60000, + windowsHide: true, + cwd: repoPath, + encoding: 'utf8' + }); + + // Parse JSON output + try { + const cycles = JSON.parse(result); + // madge returns array of arrays (each cycle is an array of file paths) + return Array.isArray(cycles) ? cycles : []; + } catch { + return []; + } + } catch { + // Tool execution failed + return null; + } +} + +/** + * Run complexity analysis using escomplex + * + * @param {string} repoPath - Repository root path + * @param {string[]} targetFiles - Files to analyze + * @param {Object} options - Options + * @returns {Array|null} Complexity results, or null if tool not available + */ +function runComplexityAnalysis(repoPath, targetFiles, options = {}) { + if (!isToolAvailable(CLI_TOOLS.escomplex.checkCommand)) { + return null; + } + + const results = []; + + // escomplex works on individual files + for (const file of targetFiles) { + // Only analyze JS/TS files + if (!file.match(/\.[jt]sx?$/)) continue; + + const filePath = path.isAbsolute(file) ? file : path.join(repoPath, file); + + try { + // Escape file path to prevent command injection + const safeFilePath = escapeDoubleQuotes(filePath); + const command = `escomplex "${safeFilePath}" --format json`; + + const result = execSync(command, { + stdio: 'pipe', + timeout: 30000, + windowsHide: true, + cwd: repoPath, + encoding: 'utf8' + }); + + try { + const report = JSON.parse(result); + + // Extract function-level complexity + if (report.functions) { + for (const fn of report.functions) { + results.push({ + file, + name: fn.name || 'anonymous', + line: fn.line || 0, + complexity: fn.cyclomatic || 0, + halstead: fn.halstead?.difficulty || 0, + sloc: fn.sloc?.logical || 0 + }); + } + } + + // Also include module-level metrics + if (report.aggregate) { + results.push({ + file, + name: 'module', + line: 0, + complexity: report.aggregate.cyclomatic || 0, + halstead: report.aggregate.halstead?.difficulty || 0, + sloc: report.aggregate.sloc?.logical || 0, + maintainability: report.maintainability || 0 + }); + } + } catch { + // JSON parsing failed for this file + } + } catch { + // Tool execution failed for this file + } + } + + return results.length > 0 ? results : null; +} + +/** + * Get user-friendly message about missing tools (language-aware) + * + * @param {string[]} missingTools - Array of missing tool names + * @param {string[]} [languages] - Detected languages (for context in message) + * @returns {string} Formatted message + */ +function getMissingToolsMessage(missingTools, languages = null) { + if (!missingTools || missingTools.length === 0) { + return ''; + } + + // Filter to only known tools + const validTools = missingTools.filter(t => CLI_TOOLS[t]); + if (validTools.length === 0) { + return ''; + } + + let message = '\n## Enhanced Analysis Available\n\n'; + + if (languages && languages.length > 0) { + message += `Detected project languages: ${languages.join(', ')}\n\n`; + } + + message += 'For deeper analysis, consider installing:\n\n'; + + for (const toolName of validTools) { + const tool = CLI_TOOLS[toolName]; + if (tool) { + message += `- **${tool.name}**: ${tool.description}\n`; + message += ` Install: \`${tool.installHint}\`\n`; + } + } + + message += '\nThese tools are optional and enhance detection capabilities.\n'; + + return message; +} + +/** + * Get all CLI tool definitions + * + * @returns {Object} CLI tool definitions + */ +function getToolDefinitions() { + return { ...CLI_TOOLS }; +} + +/** + * Get supported languages list + * + * @returns {string[]} Array of supported language names + */ +function getSupportedLanguages() { + return [...SUPPORTED_LANGUAGES]; +} + +module.exports = { + detectAvailableTools, + detectProjectLanguages, + getToolsForLanguages, + getToolAvailabilityForRepo, + runDuplicateDetection, + runDependencyAnalysis, + runComplexityAnalysis, + getMissingToolsMessage, + getToolDefinitions, + getSupportedLanguages, + clearCache, + // Exported for testing + isToolAvailable, + CLI_TOOLS, + SUPPORTED_LANGUAGES +}; diff --git a/plugins/reality-check/lib/patterns/pipeline.js b/plugins/reality-check/lib/patterns/pipeline.js new file mode 100644 index 00000000..1b630ad7 --- /dev/null +++ b/plugins/reality-check/lib/patterns/pipeline.js @@ -0,0 +1,553 @@ +/** + * Slop Detection Pipeline + * + * 3-phase detection pipeline orchestrator: + * - Phase 1 (built-in): regex patterns + multi-pass analyzers - always runs + * - Phase 2 (optional): CLI tools (jscpd, madge, escomplex) - if available + * - Phase 3 (LLM handoff): certainty-tagged findings for agent review + * + * Inherits modes from deslop-around: report (analyze only) vs apply (fix issues) + * + * @module patterns/pipeline + * @author Avi Fenesh + * @license MIT + */ + +const path = require('path'); +const fs = require('fs'); +const slopPatterns = require('./slop-patterns'); +const analyzers = require('./slop-analyzers'); + +/** + * Certainty levels for findings + * HIGH: Single regex match - definitive + * MEDIUM: Multi-pass analysis - requires context + * LOW: Heuristic/CLI tool - needs verification + */ +const CERTAINTY = { + HIGH: 'HIGH', + MEDIUM: 'MEDIUM', + LOW: 'LOW' +}; + +/** + * Thoroughness levels + * quick: Phase 1 regex only - fastest + * normal: Phase 1 + multi-pass analyzers - balanced + * deep: Phase 1 + Phase 2 CLI tools (if available) - thorough + */ +const THOROUGHNESS = { + QUICK: 'quick', + NORMAL: 'normal', + DEEP: 'deep' +}; + +/** + * Run the slop detection pipeline + * + * @param {string} repoPath - Repository root path + * @param {Object} options - Pipeline options + * @param {string} [options.thoroughness='normal'] - quick | normal | deep + * @param {string[]} [options.targetFiles] - Specific files to analyze (defaults to all source files) + * @param {string} [options.language] - Filter to specific language + * @param {string} [options.mode='report'] - report | apply + * @param {Object} [options.cliTools] - Pre-detected CLI tools (from detectAvailableTools) + * @returns {Object} Pipeline results: { findings, summary, phase3Prompt, missingTools } + */ +function runPipeline(repoPath, options = {}) { + const thoroughness = options.thoroughness || THOROUGHNESS.NORMAL; + const mode = options.mode || 'report'; + const language = options.language || null; + + const findings = []; + const missingTools = []; + let cliTools = options.cliTools || null; + + // Get target files + let targetFiles = options.targetFiles; + if (!targetFiles || targetFiles.length === 0) { + const result = analyzers.countSourceFiles(repoPath, { + maxFiles: 1000, + includeTests: false + }); + targetFiles = result.files; + } + + // Phase 1: Built-in regex patterns (always runs) + const phase1Results = runPhase1(repoPath, targetFiles, language); + findings.push(...phase1Results); + + // Phase 1b: Multi-pass analyzers (if normal or deep) + if (thoroughness !== THOROUGHNESS.QUICK) { + const multiPassResults = runMultiPassAnalyzers(repoPath, targetFiles); + findings.push(...multiPassResults); + } + + // Phase 2: CLI tools (only if deep and tools available) + if (thoroughness === THOROUGHNESS.DEEP) { + // Lazy-load CLI enhancers to avoid circular dependencies + const cliEnhancers = require('./cli-enhancers'); + + if (!cliTools) { + cliTools = cliEnhancers.detectAvailableTools(); + } + + // Track missing tools for user notification + if (!cliTools.jscpd) missingTools.push('jscpd'); + if (!cliTools.madge) missingTools.push('madge'); + if (!cliTools.escomplex) missingTools.push('escomplex'); + + const phase2Results = runPhase2(repoPath, cliTools, targetFiles); + findings.push(...phase2Results); + } + + // Build summary + const summary = buildSummary(findings); + + // Generate Phase 3 handoff prompt + const phase3Prompt = formatHandoffPrompt(findings, mode); + + return { + findings, + summary, + phase3Prompt, + missingTools, + metadata: { + repoPath, + thoroughness, + mode, + filesAnalyzed: targetFiles.length, + timestamp: new Date().toISOString() + } + }; +} + +/** + * Phase 1: Run built-in regex patterns against target files + * + * @param {string} repoPath - Repository root + * @param {string[]} targetFiles - Files to analyze + * @param {string|null} language - Optional language filter + * @returns {Array} Findings with HIGH certainty + */ +function runPhase1(repoPath, targetFiles, language) { + const findings = []; + + // Get patterns (filtered by language if specified) + const patterns = language + ? slopPatterns.getPatternsForLanguage(language) + : slopPatterns.slopPatterns; + + for (const file of targetFiles) { + // Skip if language filter doesn't match file extension + if (language) { + const fileLanguage = analyzers.detectLanguage(file); + if (fileLanguage !== language && fileLanguage !== 'js') continue; + } + + const filePath = path.isAbsolute(file) ? file : path.join(repoPath, file); + + let content; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch { + continue; // Skip unreadable files + } + + const lines = content.split('\n'); + + for (const [patternName, pattern] of Object.entries(patterns)) { + // Skip multi-pass patterns (handled separately) + if (pattern.requiresMultiPass) continue; + + // Skip if no regex pattern + if (!pattern.pattern) continue; + + // Skip if file matches exclude patterns + if (slopPatterns.isFileExcluded(file, pattern.exclude)) continue; + + // Check each line + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (pattern.pattern.test(line)) { + findings.push({ + file, + line: i + 1, + patternName, + severity: pattern.severity, + certainty: CERTAINTY.HIGH, + description: pattern.description, + autoFix: pattern.autoFix, + content: line.trim().substring(0, 100), + phase: 1 + }); + } + } + } + } + + return findings; +} + +/** + * Run multi-pass analyzers (doc/code ratio, verbosity, etc.) + * + * @param {string} repoPath - Repository root + * @param {string[]} targetFiles - Files to analyze + * @returns {Array} Findings with MEDIUM certainty + */ +function runMultiPassAnalyzers(repoPath, targetFiles) { + const findings = []; + + // Get multi-pass pattern definitions for thresholds + const multiPassPatterns = slopPatterns.getMultiPassPatterns(); + + for (const file of targetFiles) { + const filePath = path.isAbsolute(file) ? file : path.join(repoPath, file); + const lang = analyzers.detectLanguage(file); + + // Skip non-JS files for doc/code ratio (JSDoc specific) + if (lang !== 'js') continue; + + let content; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch { + continue; + } + + // Doc/code ratio analysis + const docCodePattern = multiPassPatterns.doc_code_ratio_js; + if (docCodePattern) { + const docRatioViolations = analyzers.analyzeDocCodeRatio(content, { + minFunctionLines: docCodePattern.minFunctionLines || 3, + maxRatio: docCodePattern.maxRatio || 3.0 + }); + + for (const v of docRatioViolations) { + findings.push({ + file, + line: v.line, + patternName: 'doc_code_ratio_js', + severity: docCodePattern.severity, + certainty: CERTAINTY.MEDIUM, + description: `${docCodePattern.description} (${v.docLines} doc lines / ${v.codeLines} code lines = ${v.ratio}x)`, + autoFix: docCodePattern.autoFix, + content: `Function at line ${v.line}`, + phase: 1, + details: { docLines: v.docLines, codeLines: v.codeLines, ratio: v.ratio } + }); + } + } + + // Verbosity ratio analysis + const verbosityPattern = multiPassPatterns.verbosity_ratio; + if (verbosityPattern) { + const verbosityViolations = analyzers.analyzeVerbosityRatio(content, { + minCodeLines: verbosityPattern.minCodeLines || 3, + maxCommentRatio: verbosityPattern.maxCommentRatio || 2.0, + filePath: file + }); + + for (const v of verbosityViolations) { + findings.push({ + file, + line: v.line, + patternName: 'verbosity_ratio', + severity: verbosityPattern.severity, + certainty: CERTAINTY.MEDIUM, + description: `${verbosityPattern.description} (${v.commentLines} comment lines / ${v.codeLines} code lines = ${v.ratio}x)`, + autoFix: verbosityPattern.autoFix, + content: `Function at line ${v.line}`, + phase: 1, + details: { commentLines: v.commentLines, codeLines: v.codeLines, ratio: v.ratio } + }); + } + } + } + + // Project-level analyzers (run once, not per-file) + const overEngPattern = multiPassPatterns.over_engineering_metrics; + if (overEngPattern) { + const overEngResult = analyzers.analyzeOverEngineering(repoPath, { + fileRatioThreshold: overEngPattern.fileRatioThreshold || 20, + linesPerExportThreshold: overEngPattern.linesPerExportThreshold || 500, + depthThreshold: overEngPattern.depthThreshold || 4 + }); + + for (const v of overEngResult.violations) { + findings.push({ + file: 'project-level', + line: 0, + patternName: 'over_engineering_metrics', + severity: v.severity, + certainty: CERTAINTY.MEDIUM, + description: `Over-engineering: ${v.type} - ${v.value} (threshold: ${v.threshold})`, + autoFix: 'flag', + content: v.value, + phase: 1, + details: v.details + }); + } + } + + // Buzzword inflation analysis + const buzzwordPattern = multiPassPatterns.buzzword_inflation; + if (buzzwordPattern) { + const buzzwordResult = analyzers.analyzeBuzzwordInflation(repoPath, { + minEvidenceMatches: buzzwordPattern.minEvidenceMatches || 2 + }); + + for (const v of buzzwordResult.violations) { + findings.push({ + file: v.file, + line: v.line, + patternName: 'buzzword_inflation', + severity: v.severity, + certainty: CERTAINTY.MEDIUM, + description: v.message, + autoFix: 'flag', + content: v.claim, + phase: 1, + details: { buzzword: v.buzzword, category: v.category, evidenceCount: v.evidenceCount } + }); + } + } + + // Infrastructure without implementation + const infraPattern = multiPassPatterns.infrastructure_without_implementation; + if (infraPattern) { + const infraResult = analyzers.analyzeInfrastructureWithoutImplementation(repoPath); + + for (const v of infraResult.violations) { + findings.push({ + file: v.file, + line: v.line, + patternName: 'infrastructure_without_implementation', + severity: v.severity, + certainty: CERTAINTY.MEDIUM, + description: v.message, + autoFix: 'flag', + content: v.content, + phase: 1, + details: { varName: v.varName, type: v.type } + }); + } + } + + return findings; +} + +/** + * Phase 2: Run CLI tools (if available) + * + * @param {string} repoPath - Repository root + * @param {Object} cliTools - Available CLI tools { jscpd, madge, escomplex } + * @param {string[]} targetFiles - Files to analyze + * @returns {Array} Findings with LOW certainty + */ +function runPhase2(repoPath, cliTools, targetFiles) { + const findings = []; + const cliEnhancers = require('./cli-enhancers'); + + // Duplicate detection with jscpd + if (cliTools.jscpd) { + const duplicates = cliEnhancers.runDuplicateDetection(repoPath); + if (duplicates) { + for (const dup of duplicates) { + findings.push({ + file: dup.firstFile, + line: dup.firstLine, + patternName: 'code_duplication', + severity: 'medium', + certainty: CERTAINTY.LOW, + description: `Code duplication: ${dup.lines} lines duplicated in ${dup.secondFile}:${dup.secondLine}`, + autoFix: 'flag', + content: `${dup.lines} lines duplicated`, + phase: 2, + details: dup + }); + } + } + } + + // Circular dependencies with madge + if (cliTools.madge) { + const circularDeps = cliEnhancers.runDependencyAnalysis(repoPath); + if (circularDeps) { + for (const cycle of circularDeps) { + findings.push({ + file: cycle[0], + line: 0, + patternName: 'circular_dependency', + severity: 'high', + certainty: CERTAINTY.LOW, + description: `Circular dependency: ${cycle.join(' -> ')}`, + autoFix: 'flag', + content: cycle.join(' -> '), + phase: 2, + details: { cycle } + }); + } + } + } + + // Complexity analysis with escomplex + if (cliTools.escomplex) { + const complexityResults = cliEnhancers.runComplexityAnalysis(repoPath, targetFiles); + if (complexityResults) { + for (const result of complexityResults) { + if (result.complexity > 10) { // High cyclomatic complexity threshold + findings.push({ + file: result.file, + line: result.line || 0, + patternName: 'high_complexity', + severity: result.complexity > 20 ? 'high' : 'medium', + certainty: CERTAINTY.LOW, + description: `High cyclomatic complexity: ${result.complexity} in ${result.name}`, + autoFix: 'flag', + content: `${result.name}: complexity ${result.complexity}`, + phase: 2, + details: result + }); + } + } + } + } + + return findings; +} + +/** + * Build summary statistics from findings + * + * @param {Array} findings - All findings + * @returns {Object} Summary statistics + */ +function buildSummary(findings) { + const summary = { + total: findings.length, + bySeverity: { critical: 0, high: 0, medium: 0, low: 0 }, + byCertainty: { HIGH: 0, MEDIUM: 0, LOW: 0 }, + byPhase: { 1: 0, 2: 0 }, + byAutoFix: { remove: 0, replace: 0, add_logging: 0, flag: 0, none: 0 }, + topPatterns: {} + }; + + for (const f of findings) { + summary.bySeverity[f.severity] = (summary.bySeverity[f.severity] || 0) + 1; + summary.byCertainty[f.certainty] = (summary.byCertainty[f.certainty] || 0) + 1; + summary.byPhase[f.phase] = (summary.byPhase[f.phase] || 0) + 1; + summary.byAutoFix[f.autoFix] = (summary.byAutoFix[f.autoFix] || 0) + 1; + summary.topPatterns[f.patternName] = (summary.topPatterns[f.patternName] || 0) + 1; + } + + return summary; +} + +/** + * Format handoff prompt for LLM (Phase 3) + * + * Creates a token-efficient prompt for the agent to review findings. + * Groups by certainty level with action guidance: + * - HIGH: Apply directly (if apply mode) + * - MEDIUM: Verify context before applying + * - LOW: Use judgment, may be false positive + * + * @param {Array} findings - All findings + * @param {string} mode - report | apply + * @returns {string} Formatted prompt + */ +function formatHandoffPrompt(findings, mode) { + if (findings.length === 0) { + return '## Slop Detection Results\n\nNo issues detected.'; + } + + // Group findings by certainty + const byGroup = { + HIGH: findings.filter(f => f.certainty === CERTAINTY.HIGH), + MEDIUM: findings.filter(f => f.certainty === CERTAINTY.MEDIUM), + LOW: findings.filter(f => f.certainty === CERTAINTY.LOW) + }; + + let prompt = '## Slop Detection Results\n\n'; + prompt += `Mode: **${mode}** | Total: ${findings.length} findings\n\n`; + + // HIGH certainty - definitive matches + if (byGroup.HIGH.length > 0) { + prompt += '### HIGH Certainty (Definitive - trust these)\n\n'; + if (mode === 'apply') { + prompt += '_Action: Apply fixes directly for autoFix patterns._\n\n'; + } + prompt += formatFindingsList(byGroup.HIGH); + prompt += '\n'; + } + + // MEDIUM certainty - needs context verification + if (byGroup.MEDIUM.length > 0) { + prompt += '### MEDIUM Certainty (Verify context)\n\n'; + prompt += '_Action: Review surrounding code before applying._\n\n'; + prompt += formatFindingsList(byGroup.MEDIUM); + prompt += '\n'; + } + + // LOW certainty - use judgment + if (byGroup.LOW.length > 0) { + prompt += '### LOW Certainty (Use judgment)\n\n'; + prompt += '_Action: May be false positives. Investigate before acting._\n\n'; + prompt += formatFindingsList(byGroup.LOW); + prompt += '\n'; + } + + // Action summary + prompt += '### Action Summary\n\n'; + const autoFixable = findings.filter(f => f.autoFix && f.autoFix !== 'flag' && f.autoFix !== 'none'); + const needsReview = findings.filter(f => f.autoFix === 'flag' || f.autoFix === 'none'); + + prompt += `- Auto-fixable: ${autoFixable.length}\n`; + prompt += `- Needs manual review: ${needsReview.length}\n`; + + return prompt; +} + +/** + * Format a list of findings for the prompt + * + * @param {Array} findings - Findings to format + * @returns {string} Formatted list + */ +function formatFindingsList(findings) { + // Group by file for compact output + const byFile = {}; + for (const f of findings) { + if (!byFile[f.file]) byFile[f.file] = []; + byFile[f.file].push(f); + } + + let output = ''; + for (const [file, fileFindings] of Object.entries(byFile)) { + output += `**${file}**\n`; + for (const f of fileFindings) { + const fixTag = f.autoFix && f.autoFix !== 'flag' && f.autoFix !== 'none' + ? ` [${f.autoFix}]` + : ''; + output += `- L${f.line}: ${f.description}${fixTag}\n`; + } + output += '\n'; + } + + return output; +} + +module.exports = { + runPipeline, + // Exported for testing + runPhase1, + runMultiPassAnalyzers, + runPhase2, + buildSummary, + formatHandoffPrompt, + // Constants + CERTAINTY, + THOROUGHNESS +}; diff --git a/plugins/ship/lib/config/index.js b/plugins/ship/lib/config/index.js new file mode 100644 index 00000000..25bffeb2 --- /dev/null +++ b/plugins/ship/lib/config/index.js @@ -0,0 +1,14 @@ +/** + * Configuration Module + * + * Placeholder for future configuration management. + * This module exists to satisfy lib/index.js imports. + * + * @module config + * @author Avi Fenesh + * @license MIT + */ + +module.exports = { + // Configuration will be added as needed +}; diff --git a/plugins/ship/lib/index.js b/plugins/ship/lib/index.js index f927426e..860cb976 100644 --- a/plugins/ship/lib/index.js +++ b/plugins/ship/lib/index.js @@ -14,6 +14,8 @@ const detectPlatform = require('./platform/detect-platform'); const verifyTools = require('./platform/verify-tools'); const reviewPatterns = require('./patterns/review-patterns'); const slopPatterns = require('./patterns/slop-patterns'); +const pipeline = require('./patterns/pipeline'); +const cliEnhancers = require('./patterns/cli-enhancers'); const workflowState = require('./state/workflow-state'); const contextOptimizer = require('./utils/context-optimizer'); const shellEscape = require('./utils/shell-escape'); @@ -65,7 +67,32 @@ const patterns = { * Slop patterns for AI-generated code detection * @see module:patterns/slop-patterns */ - slop: slopPatterns + slop: slopPatterns, + + /** + * Slop detection pipeline orchestrator + * @see module:patterns/pipeline + */ + pipeline: { + runPipeline: pipeline.runPipeline, + CERTAINTY: pipeline.CERTAINTY, + THOROUGHNESS: pipeline.THOROUGHNESS, + formatHandoffPrompt: pipeline.formatHandoffPrompt, + buildSummary: pipeline.buildSummary + }, + + /** + * Optional CLI tool enhancers for deep analysis + * @see module:patterns/cli-enhancers + */ + cliEnhancers: { + detectAvailableTools: cliEnhancers.detectAvailableTools, + runDuplicateDetection: cliEnhancers.runDuplicateDetection, + runDependencyAnalysis: cliEnhancers.runDependencyAnalysis, + runComplexityAnalysis: cliEnhancers.runComplexityAnalysis, + getMissingToolsMessage: cliEnhancers.getMissingToolsMessage, + CLI_TOOLS: cliEnhancers.CLI_TOOLS + } }; /** @@ -161,6 +188,8 @@ module.exports = { verifyTools, reviewPatterns, slopPatterns, + pipeline, + cliEnhancers, workflowState, contextOptimizer, shellEscape, diff --git a/plugins/ship/lib/patterns/cli-enhancers.js b/plugins/ship/lib/patterns/cli-enhancers.js new file mode 100644 index 00000000..6cfac1da --- /dev/null +++ b/plugins/ship/lib/patterns/cli-enhancers.js @@ -0,0 +1,602 @@ +/** + * CLI Enhancers for Slop Detection Pipeline + * + * Optional CLI tool integration for Phase 2 detection. + * All tools are user-installed globally - zero npm dependencies for this module. + * Functions gracefully degrade when tools are not available. + * + * Supported languages: javascript, typescript, python, rust, go + * + * @module patterns/cli-enhancers + * @author Avi Fenesh + * @license MIT + */ + +const { execSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const { escapeDoubleQuotes } = require('../utils/shell-escape'); + +/** + * Cache for tool availability (per-repo) + * Key: repoPath, Value: { tools: {...}, languages: [...], timestamp: Date } + */ +const toolCache = new Map(); +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Supported languages (must match slop-patterns.js) + */ +const SUPPORTED_LANGUAGES = ['javascript', 'typescript', 'python', 'rust', 'go']; + +/** + * CLI tool definitions organized by language + * Only includes tools for supported languages + */ +const CLI_TOOLS = { + // Cross-language tools + jscpd: { + name: 'jscpd', + description: 'Copy/paste detector for code duplication', + checkCommand: 'jscpd --version', + installHint: 'npm install -g jscpd', + languages: ['javascript', 'typescript', 'python', 'go', 'rust'] + }, + + // JavaScript/TypeScript tools + madge: { + name: 'madge', + description: 'Circular dependency detector', + checkCommand: 'madge --version', + installHint: 'npm install -g madge', + languages: ['javascript', 'typescript'] + }, + escomplex: { + name: 'escomplex', + description: 'Cyclomatic complexity analyzer', + checkCommand: 'escomplex --version', + installHint: 'npm install -g escomplex', + languages: ['javascript'] + }, + + // Python tools + pylint: { + name: 'pylint', + description: 'Python linter with complexity analysis', + checkCommand: 'pylint --version', + installHint: 'pip install pylint', + languages: ['python'] + }, + radon: { + name: 'radon', + description: 'Python complexity and maintainability metrics', + checkCommand: 'radon --version', + installHint: 'pip install radon', + languages: ['python'] + }, + + // Go tools + golangci_lint: { + name: 'golangci-lint', + description: 'Go linters aggregator with complexity checks', + checkCommand: 'golangci-lint --version', + installHint: 'go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest', + languages: ['go'] + }, + + // Rust tools + clippy: { + name: 'cargo-clippy', + description: 'Rust linter with code smell detection', + checkCommand: 'cargo clippy --version', + installHint: 'rustup component add clippy', + languages: ['rust'] + } +}; + +/** + * Check if a CLI tool is available in PATH + * + * @param {string} command - Command to check (e.g., 'jscpd --version') + * @returns {boolean} True if tool is available + */ +function isToolAvailable(command) { + try { + execSync(command, { + stdio: 'pipe', + timeout: 5000, + windowsHide: true + }); + return true; + } catch { + return false; + } +} + +/** + * Get cache key for a repo + * @param {string} repoPath - Repository root path + * @returns {string} Cache key + */ +function getCacheKey(repoPath) { + return path.resolve(repoPath); +} + +/** + * Check if cache is valid + * @param {Object} cacheEntry - Cache entry + * @returns {boolean} True if cache is still valid + */ +function isCacheValid(cacheEntry) { + if (!cacheEntry) return false; + return Date.now() - cacheEntry.timestamp < CACHE_TTL_MS; +} + +/** + * Clear the tool cache (useful for testing) + */ +function clearCache() { + toolCache.clear(); +} + +/** + * Detect primary language(s) of a repository based on file extensions and config files + * + * @param {string} repoPath - Repository root path + * @returns {string[]} Array of detected languages (only supported ones) + */ +function detectProjectLanguages(repoPath) { + const languages = new Set(); + + // Check for language-specific config files + const configIndicators = { + 'package.json': ['javascript', 'typescript'], + 'tsconfig.json': ['typescript'], + 'requirements.txt': ['python'], + 'setup.py': ['python'], + 'pyproject.toml': ['python'], + 'Pipfile': ['python'], + 'go.mod': ['go'], + 'go.sum': ['go'], + 'Cargo.toml': ['rust'] + }; + + for (const [file, langs] of Object.entries(configIndicators)) { + if (fs.existsSync(path.join(repoPath, file))) { + langs.forEach(l => languages.add(l)); + } + } + + // If no config files found, scan for source files + if (languages.size === 0) { + const extensionMap = { + '.js': 'javascript', + '.jsx': 'javascript', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.ts': 'typescript', + '.tsx': 'typescript', + '.py': 'python', + '.go': 'go', + '.rs': 'rust' + }; + + // Quick scan of top-level and src/ directories + const dirsToScan = [repoPath, path.join(repoPath, 'src'), path.join(repoPath, 'lib')]; + + for (const dir of dirsToScan) { + if (!fs.existsSync(dir)) continue; + try { + const files = fs.readdirSync(dir); + for (const file of files) { + const ext = path.extname(file).toLowerCase(); + if (extensionMap[ext]) { + languages.add(extensionMap[ext]); + } + } + } catch { + // Directory not readable + } + } + } + + // Filter to only supported languages + const result = Array.from(languages).filter(l => SUPPORTED_LANGUAGES.includes(l)); + + // Default to javascript if nothing detected + if (result.length === 0) { + result.push('javascript'); + } + + return result; +} + +/** + * Get tools relevant for specific languages + * + * @param {string[]} languages - Array of language names + * @returns {Object} Filtered CLI_TOOLS for the specified languages + */ +function getToolsForLanguages(languages) { + const relevant = {}; + + for (const [toolName, tool] of Object.entries(CLI_TOOLS)) { + if (tool.languages.some(lang => languages.includes(lang))) { + relevant[toolName] = tool; + } + } + + return relevant; +} + +/** + * Detect which CLI tools are available on the system + * Uses cache when available + * + * @param {string[]} [languages] - Optional languages to filter tools for + * @param {string} [repoPath] - Optional repo path for caching + * @returns {Object} Object with tool names as keys and availability as boolean values + */ +function detectAvailableTools(languages = null, repoPath = null) { + // Check cache if repoPath provided + if (repoPath) { + const cacheKey = getCacheKey(repoPath); + const cached = toolCache.get(cacheKey); + if (isCacheValid(cached)) { + // Return cached tools filtered by languages if specified + if (languages) { + const relevantTools = getToolsForLanguages(languages); + const filtered = {}; + for (const name of Object.keys(relevantTools)) { + filtered[name] = cached.tools[name] || false; + } + return filtered; + } + return { ...cached.tools }; + } + } + + // Get tools to check + const toolsToCheck = languages ? getToolsForLanguages(languages) : CLI_TOOLS; + const result = {}; + + for (const [toolName, tool] of Object.entries(toolsToCheck)) { + result[toolName] = isToolAvailable(tool.checkCommand); + } + + // Update cache if repoPath provided + if (repoPath) { + const cacheKey = getCacheKey(repoPath); + const existing = toolCache.get(cacheKey) || {}; + toolCache.set(cacheKey, { + tools: { ...existing.tools, ...result }, + languages: languages || existing.languages || [], + timestamp: Date.now() + }); + } + + return result; +} + +/** + * Get tool availability for a specific repo (with caching) + * + * @param {string} repoPath - Repository root path + * @param {Object} [options] - Options + * @param {boolean} [options.forceRefresh=false] - Force cache refresh + * @returns {{ available: Object, missing: string[], languages: string[] }} Tool availability info + */ +function getToolAvailabilityForRepo(repoPath, options = {}) { + const cacheKey = getCacheKey(repoPath); + + // Check cache unless force refresh + if (!options.forceRefresh) { + const cached = toolCache.get(cacheKey); + if (isCacheValid(cached) && cached.languages && cached.languages.length > 0) { + const relevantTools = getToolsForLanguages(cached.languages); + const missing = Object.keys(relevantTools).filter(t => !cached.tools[t]); + return { + available: { ...cached.tools }, + missing, + languages: [...cached.languages] + }; + } + } + + // Detect languages + const languages = detectProjectLanguages(repoPath); + + // Detect tools for those languages + const available = detectAvailableTools(languages, repoPath); + + // Find missing tools + const relevantTools = getToolsForLanguages(languages); + const missing = Object.keys(relevantTools).filter(t => !available[t]); + + // Update cache + toolCache.set(cacheKey, { + tools: available, + languages, + timestamp: Date.now() + }); + + return { available, missing, languages }; +} + +/** + * Run duplicate code detection using jscpd + * + * @param {string} repoPath - Repository root path + * @param {Object} options - Options + * @param {number} [options.minLines=5] - Minimum lines for duplicate detection + * @param {number} [options.minTokens=50] - Minimum tokens for duplicate detection + * @returns {Array|null} Duplicates found, or null if tool not available + */ +function runDuplicateDetection(repoPath, options = {}) { + if (!isToolAvailable(CLI_TOOLS.jscpd.checkCommand)) { + return null; + } + + const minLines = options.minLines || 5; + const minTokens = options.minTokens || 50; + + try { + // Run jscpd with JSON output + // Escape repoPath to prevent command injection + const outputPath = process.platform === 'win32' ? 'NUL' : '/dev/null'; + const safeRepoPath = escapeDoubleQuotes(repoPath); + const command = `jscpd "${safeRepoPath}" --min-lines ${minLines} --min-tokens ${minTokens} --reporters json --output ${outputPath} --silent 2>&1`; + + const result = execSync(command, { + stdio: 'pipe', + timeout: 60000, + windowsHide: true, + cwd: repoPath, + encoding: 'utf8' + }); + + // Parse JSON output + try { + const report = JSON.parse(result); + const duplicates = []; + + if (report.duplicates) { + for (const dup of report.duplicates) { + duplicates.push({ + firstFile: dup.firstFile?.name || 'unknown', + firstLine: dup.firstFile?.start || 0, + secondFile: dup.secondFile?.name || 'unknown', + secondLine: dup.secondFile?.start || 0, + lines: dup.lines || 0, + tokens: dup.tokens || 0, + fragment: dup.fragment?.substring(0, 100) || '' + }); + } + } + + return duplicates; + } catch { + // JSON parsing failed, return empty array + return []; + } + } catch { + // Tool execution failed + return null; + } +} + +/** + * Run circular dependency detection using madge + * + * @param {string} repoPath - Repository root path + * @param {Object} options - Options + * @param {string} [options.entry] - Entry file (defaults to src/index.js or index.js) + * @returns {Array|null} Circular dependency cycles, or null if tool not available + */ +function runDependencyAnalysis(repoPath, options = {}) { + if (!isToolAvailable(CLI_TOOLS.madge.checkCommand)) { + return null; + } + + // Determine entry point + let entry = options.entry; + if (!entry) { + const possibleEntries = [ + 'src/index.js', + 'src/index.ts', + 'index.js', + 'index.ts', + 'lib/index.js', + 'main.js' + ]; + + for (const e of possibleEntries) { + if (fs.existsSync(path.join(repoPath, e))) { + entry = e; + break; + } + } + } + + if (!entry) { + // No entry point found, scan entire directory + entry = '.'; + } + + try { + // Run madge with circular flag and JSON output + // Escape entry path to prevent command injection + const safeEntry = escapeDoubleQuotes(entry); + const command = `madge --circular --json "${safeEntry}"`; + + const result = execSync(command, { + stdio: 'pipe', + timeout: 60000, + windowsHide: true, + cwd: repoPath, + encoding: 'utf8' + }); + + // Parse JSON output + try { + const cycles = JSON.parse(result); + // madge returns array of arrays (each cycle is an array of file paths) + return Array.isArray(cycles) ? cycles : []; + } catch { + return []; + } + } catch { + // Tool execution failed + return null; + } +} + +/** + * Run complexity analysis using escomplex + * + * @param {string} repoPath - Repository root path + * @param {string[]} targetFiles - Files to analyze + * @param {Object} options - Options + * @returns {Array|null} Complexity results, or null if tool not available + */ +function runComplexityAnalysis(repoPath, targetFiles, options = {}) { + if (!isToolAvailable(CLI_TOOLS.escomplex.checkCommand)) { + return null; + } + + const results = []; + + // escomplex works on individual files + for (const file of targetFiles) { + // Only analyze JS/TS files + if (!file.match(/\.[jt]sx?$/)) continue; + + const filePath = path.isAbsolute(file) ? file : path.join(repoPath, file); + + try { + // Escape file path to prevent command injection + const safeFilePath = escapeDoubleQuotes(filePath); + const command = `escomplex "${safeFilePath}" --format json`; + + const result = execSync(command, { + stdio: 'pipe', + timeout: 30000, + windowsHide: true, + cwd: repoPath, + encoding: 'utf8' + }); + + try { + const report = JSON.parse(result); + + // Extract function-level complexity + if (report.functions) { + for (const fn of report.functions) { + results.push({ + file, + name: fn.name || 'anonymous', + line: fn.line || 0, + complexity: fn.cyclomatic || 0, + halstead: fn.halstead?.difficulty || 0, + sloc: fn.sloc?.logical || 0 + }); + } + } + + // Also include module-level metrics + if (report.aggregate) { + results.push({ + file, + name: 'module', + line: 0, + complexity: report.aggregate.cyclomatic || 0, + halstead: report.aggregate.halstead?.difficulty || 0, + sloc: report.aggregate.sloc?.logical || 0, + maintainability: report.maintainability || 0 + }); + } + } catch { + // JSON parsing failed for this file + } + } catch { + // Tool execution failed for this file + } + } + + return results.length > 0 ? results : null; +} + +/** + * Get user-friendly message about missing tools (language-aware) + * + * @param {string[]} missingTools - Array of missing tool names + * @param {string[]} [languages] - Detected languages (for context in message) + * @returns {string} Formatted message + */ +function getMissingToolsMessage(missingTools, languages = null) { + if (!missingTools || missingTools.length === 0) { + return ''; + } + + // Filter to only known tools + const validTools = missingTools.filter(t => CLI_TOOLS[t]); + if (validTools.length === 0) { + return ''; + } + + let message = '\n## Enhanced Analysis Available\n\n'; + + if (languages && languages.length > 0) { + message += `Detected project languages: ${languages.join(', ')}\n\n`; + } + + message += 'For deeper analysis, consider installing:\n\n'; + + for (const toolName of validTools) { + const tool = CLI_TOOLS[toolName]; + if (tool) { + message += `- **${tool.name}**: ${tool.description}\n`; + message += ` Install: \`${tool.installHint}\`\n`; + } + } + + message += '\nThese tools are optional and enhance detection capabilities.\n'; + + return message; +} + +/** + * Get all CLI tool definitions + * + * @returns {Object} CLI tool definitions + */ +function getToolDefinitions() { + return { ...CLI_TOOLS }; +} + +/** + * Get supported languages list + * + * @returns {string[]} Array of supported language names + */ +function getSupportedLanguages() { + return [...SUPPORTED_LANGUAGES]; +} + +module.exports = { + detectAvailableTools, + detectProjectLanguages, + getToolsForLanguages, + getToolAvailabilityForRepo, + runDuplicateDetection, + runDependencyAnalysis, + runComplexityAnalysis, + getMissingToolsMessage, + getToolDefinitions, + getSupportedLanguages, + clearCache, + // Exported for testing + isToolAvailable, + CLI_TOOLS, + SUPPORTED_LANGUAGES +}; diff --git a/plugins/ship/lib/patterns/pipeline.js b/plugins/ship/lib/patterns/pipeline.js new file mode 100644 index 00000000..1b630ad7 --- /dev/null +++ b/plugins/ship/lib/patterns/pipeline.js @@ -0,0 +1,553 @@ +/** + * Slop Detection Pipeline + * + * 3-phase detection pipeline orchestrator: + * - Phase 1 (built-in): regex patterns + multi-pass analyzers - always runs + * - Phase 2 (optional): CLI tools (jscpd, madge, escomplex) - if available + * - Phase 3 (LLM handoff): certainty-tagged findings for agent review + * + * Inherits modes from deslop-around: report (analyze only) vs apply (fix issues) + * + * @module patterns/pipeline + * @author Avi Fenesh + * @license MIT + */ + +const path = require('path'); +const fs = require('fs'); +const slopPatterns = require('./slop-patterns'); +const analyzers = require('./slop-analyzers'); + +/** + * Certainty levels for findings + * HIGH: Single regex match - definitive + * MEDIUM: Multi-pass analysis - requires context + * LOW: Heuristic/CLI tool - needs verification + */ +const CERTAINTY = { + HIGH: 'HIGH', + MEDIUM: 'MEDIUM', + LOW: 'LOW' +}; + +/** + * Thoroughness levels + * quick: Phase 1 regex only - fastest + * normal: Phase 1 + multi-pass analyzers - balanced + * deep: Phase 1 + Phase 2 CLI tools (if available) - thorough + */ +const THOROUGHNESS = { + QUICK: 'quick', + NORMAL: 'normal', + DEEP: 'deep' +}; + +/** + * Run the slop detection pipeline + * + * @param {string} repoPath - Repository root path + * @param {Object} options - Pipeline options + * @param {string} [options.thoroughness='normal'] - quick | normal | deep + * @param {string[]} [options.targetFiles] - Specific files to analyze (defaults to all source files) + * @param {string} [options.language] - Filter to specific language + * @param {string} [options.mode='report'] - report | apply + * @param {Object} [options.cliTools] - Pre-detected CLI tools (from detectAvailableTools) + * @returns {Object} Pipeline results: { findings, summary, phase3Prompt, missingTools } + */ +function runPipeline(repoPath, options = {}) { + const thoroughness = options.thoroughness || THOROUGHNESS.NORMAL; + const mode = options.mode || 'report'; + const language = options.language || null; + + const findings = []; + const missingTools = []; + let cliTools = options.cliTools || null; + + // Get target files + let targetFiles = options.targetFiles; + if (!targetFiles || targetFiles.length === 0) { + const result = analyzers.countSourceFiles(repoPath, { + maxFiles: 1000, + includeTests: false + }); + targetFiles = result.files; + } + + // Phase 1: Built-in regex patterns (always runs) + const phase1Results = runPhase1(repoPath, targetFiles, language); + findings.push(...phase1Results); + + // Phase 1b: Multi-pass analyzers (if normal or deep) + if (thoroughness !== THOROUGHNESS.QUICK) { + const multiPassResults = runMultiPassAnalyzers(repoPath, targetFiles); + findings.push(...multiPassResults); + } + + // Phase 2: CLI tools (only if deep and tools available) + if (thoroughness === THOROUGHNESS.DEEP) { + // Lazy-load CLI enhancers to avoid circular dependencies + const cliEnhancers = require('./cli-enhancers'); + + if (!cliTools) { + cliTools = cliEnhancers.detectAvailableTools(); + } + + // Track missing tools for user notification + if (!cliTools.jscpd) missingTools.push('jscpd'); + if (!cliTools.madge) missingTools.push('madge'); + if (!cliTools.escomplex) missingTools.push('escomplex'); + + const phase2Results = runPhase2(repoPath, cliTools, targetFiles); + findings.push(...phase2Results); + } + + // Build summary + const summary = buildSummary(findings); + + // Generate Phase 3 handoff prompt + const phase3Prompt = formatHandoffPrompt(findings, mode); + + return { + findings, + summary, + phase3Prompt, + missingTools, + metadata: { + repoPath, + thoroughness, + mode, + filesAnalyzed: targetFiles.length, + timestamp: new Date().toISOString() + } + }; +} + +/** + * Phase 1: Run built-in regex patterns against target files + * + * @param {string} repoPath - Repository root + * @param {string[]} targetFiles - Files to analyze + * @param {string|null} language - Optional language filter + * @returns {Array} Findings with HIGH certainty + */ +function runPhase1(repoPath, targetFiles, language) { + const findings = []; + + // Get patterns (filtered by language if specified) + const patterns = language + ? slopPatterns.getPatternsForLanguage(language) + : slopPatterns.slopPatterns; + + for (const file of targetFiles) { + // Skip if language filter doesn't match file extension + if (language) { + const fileLanguage = analyzers.detectLanguage(file); + if (fileLanguage !== language && fileLanguage !== 'js') continue; + } + + const filePath = path.isAbsolute(file) ? file : path.join(repoPath, file); + + let content; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch { + continue; // Skip unreadable files + } + + const lines = content.split('\n'); + + for (const [patternName, pattern] of Object.entries(patterns)) { + // Skip multi-pass patterns (handled separately) + if (pattern.requiresMultiPass) continue; + + // Skip if no regex pattern + if (!pattern.pattern) continue; + + // Skip if file matches exclude patterns + if (slopPatterns.isFileExcluded(file, pattern.exclude)) continue; + + // Check each line + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (pattern.pattern.test(line)) { + findings.push({ + file, + line: i + 1, + patternName, + severity: pattern.severity, + certainty: CERTAINTY.HIGH, + description: pattern.description, + autoFix: pattern.autoFix, + content: line.trim().substring(0, 100), + phase: 1 + }); + } + } + } + } + + return findings; +} + +/** + * Run multi-pass analyzers (doc/code ratio, verbosity, etc.) + * + * @param {string} repoPath - Repository root + * @param {string[]} targetFiles - Files to analyze + * @returns {Array} Findings with MEDIUM certainty + */ +function runMultiPassAnalyzers(repoPath, targetFiles) { + const findings = []; + + // Get multi-pass pattern definitions for thresholds + const multiPassPatterns = slopPatterns.getMultiPassPatterns(); + + for (const file of targetFiles) { + const filePath = path.isAbsolute(file) ? file : path.join(repoPath, file); + const lang = analyzers.detectLanguage(file); + + // Skip non-JS files for doc/code ratio (JSDoc specific) + if (lang !== 'js') continue; + + let content; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch { + continue; + } + + // Doc/code ratio analysis + const docCodePattern = multiPassPatterns.doc_code_ratio_js; + if (docCodePattern) { + const docRatioViolations = analyzers.analyzeDocCodeRatio(content, { + minFunctionLines: docCodePattern.minFunctionLines || 3, + maxRatio: docCodePattern.maxRatio || 3.0 + }); + + for (const v of docRatioViolations) { + findings.push({ + file, + line: v.line, + patternName: 'doc_code_ratio_js', + severity: docCodePattern.severity, + certainty: CERTAINTY.MEDIUM, + description: `${docCodePattern.description} (${v.docLines} doc lines / ${v.codeLines} code lines = ${v.ratio}x)`, + autoFix: docCodePattern.autoFix, + content: `Function at line ${v.line}`, + phase: 1, + details: { docLines: v.docLines, codeLines: v.codeLines, ratio: v.ratio } + }); + } + } + + // Verbosity ratio analysis + const verbosityPattern = multiPassPatterns.verbosity_ratio; + if (verbosityPattern) { + const verbosityViolations = analyzers.analyzeVerbosityRatio(content, { + minCodeLines: verbosityPattern.minCodeLines || 3, + maxCommentRatio: verbosityPattern.maxCommentRatio || 2.0, + filePath: file + }); + + for (const v of verbosityViolations) { + findings.push({ + file, + line: v.line, + patternName: 'verbosity_ratio', + severity: verbosityPattern.severity, + certainty: CERTAINTY.MEDIUM, + description: `${verbosityPattern.description} (${v.commentLines} comment lines / ${v.codeLines} code lines = ${v.ratio}x)`, + autoFix: verbosityPattern.autoFix, + content: `Function at line ${v.line}`, + phase: 1, + details: { commentLines: v.commentLines, codeLines: v.codeLines, ratio: v.ratio } + }); + } + } + } + + // Project-level analyzers (run once, not per-file) + const overEngPattern = multiPassPatterns.over_engineering_metrics; + if (overEngPattern) { + const overEngResult = analyzers.analyzeOverEngineering(repoPath, { + fileRatioThreshold: overEngPattern.fileRatioThreshold || 20, + linesPerExportThreshold: overEngPattern.linesPerExportThreshold || 500, + depthThreshold: overEngPattern.depthThreshold || 4 + }); + + for (const v of overEngResult.violations) { + findings.push({ + file: 'project-level', + line: 0, + patternName: 'over_engineering_metrics', + severity: v.severity, + certainty: CERTAINTY.MEDIUM, + description: `Over-engineering: ${v.type} - ${v.value} (threshold: ${v.threshold})`, + autoFix: 'flag', + content: v.value, + phase: 1, + details: v.details + }); + } + } + + // Buzzword inflation analysis + const buzzwordPattern = multiPassPatterns.buzzword_inflation; + if (buzzwordPattern) { + const buzzwordResult = analyzers.analyzeBuzzwordInflation(repoPath, { + minEvidenceMatches: buzzwordPattern.minEvidenceMatches || 2 + }); + + for (const v of buzzwordResult.violations) { + findings.push({ + file: v.file, + line: v.line, + patternName: 'buzzword_inflation', + severity: v.severity, + certainty: CERTAINTY.MEDIUM, + description: v.message, + autoFix: 'flag', + content: v.claim, + phase: 1, + details: { buzzword: v.buzzword, category: v.category, evidenceCount: v.evidenceCount } + }); + } + } + + // Infrastructure without implementation + const infraPattern = multiPassPatterns.infrastructure_without_implementation; + if (infraPattern) { + const infraResult = analyzers.analyzeInfrastructureWithoutImplementation(repoPath); + + for (const v of infraResult.violations) { + findings.push({ + file: v.file, + line: v.line, + patternName: 'infrastructure_without_implementation', + severity: v.severity, + certainty: CERTAINTY.MEDIUM, + description: v.message, + autoFix: 'flag', + content: v.content, + phase: 1, + details: { varName: v.varName, type: v.type } + }); + } + } + + return findings; +} + +/** + * Phase 2: Run CLI tools (if available) + * + * @param {string} repoPath - Repository root + * @param {Object} cliTools - Available CLI tools { jscpd, madge, escomplex } + * @param {string[]} targetFiles - Files to analyze + * @returns {Array} Findings with LOW certainty + */ +function runPhase2(repoPath, cliTools, targetFiles) { + const findings = []; + const cliEnhancers = require('./cli-enhancers'); + + // Duplicate detection with jscpd + if (cliTools.jscpd) { + const duplicates = cliEnhancers.runDuplicateDetection(repoPath); + if (duplicates) { + for (const dup of duplicates) { + findings.push({ + file: dup.firstFile, + line: dup.firstLine, + patternName: 'code_duplication', + severity: 'medium', + certainty: CERTAINTY.LOW, + description: `Code duplication: ${dup.lines} lines duplicated in ${dup.secondFile}:${dup.secondLine}`, + autoFix: 'flag', + content: `${dup.lines} lines duplicated`, + phase: 2, + details: dup + }); + } + } + } + + // Circular dependencies with madge + if (cliTools.madge) { + const circularDeps = cliEnhancers.runDependencyAnalysis(repoPath); + if (circularDeps) { + for (const cycle of circularDeps) { + findings.push({ + file: cycle[0], + line: 0, + patternName: 'circular_dependency', + severity: 'high', + certainty: CERTAINTY.LOW, + description: `Circular dependency: ${cycle.join(' -> ')}`, + autoFix: 'flag', + content: cycle.join(' -> '), + phase: 2, + details: { cycle } + }); + } + } + } + + // Complexity analysis with escomplex + if (cliTools.escomplex) { + const complexityResults = cliEnhancers.runComplexityAnalysis(repoPath, targetFiles); + if (complexityResults) { + for (const result of complexityResults) { + if (result.complexity > 10) { // High cyclomatic complexity threshold + findings.push({ + file: result.file, + line: result.line || 0, + patternName: 'high_complexity', + severity: result.complexity > 20 ? 'high' : 'medium', + certainty: CERTAINTY.LOW, + description: `High cyclomatic complexity: ${result.complexity} in ${result.name}`, + autoFix: 'flag', + content: `${result.name}: complexity ${result.complexity}`, + phase: 2, + details: result + }); + } + } + } + } + + return findings; +} + +/** + * Build summary statistics from findings + * + * @param {Array} findings - All findings + * @returns {Object} Summary statistics + */ +function buildSummary(findings) { + const summary = { + total: findings.length, + bySeverity: { critical: 0, high: 0, medium: 0, low: 0 }, + byCertainty: { HIGH: 0, MEDIUM: 0, LOW: 0 }, + byPhase: { 1: 0, 2: 0 }, + byAutoFix: { remove: 0, replace: 0, add_logging: 0, flag: 0, none: 0 }, + topPatterns: {} + }; + + for (const f of findings) { + summary.bySeverity[f.severity] = (summary.bySeverity[f.severity] || 0) + 1; + summary.byCertainty[f.certainty] = (summary.byCertainty[f.certainty] || 0) + 1; + summary.byPhase[f.phase] = (summary.byPhase[f.phase] || 0) + 1; + summary.byAutoFix[f.autoFix] = (summary.byAutoFix[f.autoFix] || 0) + 1; + summary.topPatterns[f.patternName] = (summary.topPatterns[f.patternName] || 0) + 1; + } + + return summary; +} + +/** + * Format handoff prompt for LLM (Phase 3) + * + * Creates a token-efficient prompt for the agent to review findings. + * Groups by certainty level with action guidance: + * - HIGH: Apply directly (if apply mode) + * - MEDIUM: Verify context before applying + * - LOW: Use judgment, may be false positive + * + * @param {Array} findings - All findings + * @param {string} mode - report | apply + * @returns {string} Formatted prompt + */ +function formatHandoffPrompt(findings, mode) { + if (findings.length === 0) { + return '## Slop Detection Results\n\nNo issues detected.'; + } + + // Group findings by certainty + const byGroup = { + HIGH: findings.filter(f => f.certainty === CERTAINTY.HIGH), + MEDIUM: findings.filter(f => f.certainty === CERTAINTY.MEDIUM), + LOW: findings.filter(f => f.certainty === CERTAINTY.LOW) + }; + + let prompt = '## Slop Detection Results\n\n'; + prompt += `Mode: **${mode}** | Total: ${findings.length} findings\n\n`; + + // HIGH certainty - definitive matches + if (byGroup.HIGH.length > 0) { + prompt += '### HIGH Certainty (Definitive - trust these)\n\n'; + if (mode === 'apply') { + prompt += '_Action: Apply fixes directly for autoFix patterns._\n\n'; + } + prompt += formatFindingsList(byGroup.HIGH); + prompt += '\n'; + } + + // MEDIUM certainty - needs context verification + if (byGroup.MEDIUM.length > 0) { + prompt += '### MEDIUM Certainty (Verify context)\n\n'; + prompt += '_Action: Review surrounding code before applying._\n\n'; + prompt += formatFindingsList(byGroup.MEDIUM); + prompt += '\n'; + } + + // LOW certainty - use judgment + if (byGroup.LOW.length > 0) { + prompt += '### LOW Certainty (Use judgment)\n\n'; + prompt += '_Action: May be false positives. Investigate before acting._\n\n'; + prompt += formatFindingsList(byGroup.LOW); + prompt += '\n'; + } + + // Action summary + prompt += '### Action Summary\n\n'; + const autoFixable = findings.filter(f => f.autoFix && f.autoFix !== 'flag' && f.autoFix !== 'none'); + const needsReview = findings.filter(f => f.autoFix === 'flag' || f.autoFix === 'none'); + + prompt += `- Auto-fixable: ${autoFixable.length}\n`; + prompt += `- Needs manual review: ${needsReview.length}\n`; + + return prompt; +} + +/** + * Format a list of findings for the prompt + * + * @param {Array} findings - Findings to format + * @returns {string} Formatted list + */ +function formatFindingsList(findings) { + // Group by file for compact output + const byFile = {}; + for (const f of findings) { + if (!byFile[f.file]) byFile[f.file] = []; + byFile[f.file].push(f); + } + + let output = ''; + for (const [file, fileFindings] of Object.entries(byFile)) { + output += `**${file}**\n`; + for (const f of fileFindings) { + const fixTag = f.autoFix && f.autoFix !== 'flag' && f.autoFix !== 'none' + ? ` [${f.autoFix}]` + : ''; + output += `- L${f.line}: ${f.description}${fixTag}\n`; + } + output += '\n'; + } + + return output; +} + +module.exports = { + runPipeline, + // Exported for testing + runPhase1, + runMultiPassAnalyzers, + runPhase2, + buildSummary, + formatHandoffPrompt, + // Constants + CERTAINTY, + THOROUGHNESS +};