diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index cd4bf1d5..11d1b639 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,13 +1,26 @@ { "name": "awesome-slash", - "description": "8 specialized plugins for AI workflow automation - task orchestration, PR workflow, slop detection, code review, drift detection, enhancement analysis, documentation sync, and repo mapping", + "description": "9 specialized plugins for AI workflow automation - task orchestration, PR workflow, slop detection, code review, drift detection, enhancement analysis, documentation sync, repo mapping, and perf investigations", "version": "3.3.3", "owner": { "name": "Avi Fenesh", "url": "https://github.com/avifenesh" }, "repository": "https://github.com/avifenesh/awesome-slash", - "keywords": ["ai", "llm", "agents", "agentic", "claude-code", "opencode", "codex", "mcp", "automation", "workflow", "code-review", "multi-agent"], + "keywords": [ + "ai", + "llm", + "agents", + "agentic", + "claude-code", + "opencode", + "codex", + "mcp", + "automation", + "workflow", + "code-review", + "multi-agent" + ], "plugins": [ { "name": "next-task", @@ -64,12 +77,29 @@ "description": "AST-based repository map generation using ast-grep with incremental updates for faster drift analysis", "version": "3.3.3", "category": "development" + }, + { + "name": "perf", + "source": "./plugins/perf", + "description": "Rigorous performance investigation workflow with baselines, profiling, hypotheses, and evidence-backed decisions", + "version": "3.3.3", + "category": "development" } ], "mcpServer": { "name": "awesome-slash", "source": "./mcp-server", "description": "Cross-platform MCP server with 3-phase slop detection pipeline and enhance analyzers", - "tools": ["workflow_status", "workflow_start", "workflow_resume", "workflow_abort", "task_discover", "review_code", "slop_detect", "enhance_analyze", "repo_map"] + "tools": [ + "workflow_status", + "workflow_start", + "workflow_resume", + "workflow_abort", + "task_discover", + "review_code", + "slop_detect", + "enhance_analyze", + "repo_map" + ] } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dd515ca..8a2c0773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [3.3.3] - 2026-01-29 +### Added +- **/enhance Hooks Analyzer** - New hook checks for frontmatter completeness and basic safety cues +- **/enhance Skills Analyzer** - New SKILL.md checks for frontmatter and trigger phrase clarity +- **Enhance MCP Tool** - `enhance_analyze` now supports `hooks` and `skills` focus targets -### Fixed -- **Windows Path Template Substitution** - Fixed plugin path template substitution across 18 command/agent files - - Replaced `'${CLAUDE_PLUGIN_ROOT}'.replace()` with runtime `process.env` access - - Pattern: `(process.env.CLAUDE_PLUGIN_ROOT || process.env.PLUGIN_ROOT || '').replace(/\/g, '/')` - - Supports both CLAUDE_PLUGIN_ROOT and PLUGIN_ROOT for cross-platform compatibility - - Added validation for missing environment variables in bash sections - - Bash pattern: `${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}` with explicit error handling - - Fixes: Template strings in require() paths were not being substituted at runtime on Windows +### Changed +- **Enhance Orchestrator** - Expanded to run hooks/skills analyzers alongside existing enhancers ## [3.3.2] - 2026-01-29 diff --git a/README.md b/README.md index d9d12128..2fdace80 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ AI models can write code. That's not the hard part anymore. The hard part is eve | Section | What's there | |---------|--------------| -| [Commands](#commands) | All 8 commands with jump links | +| [Commands](#commands) | All 9 commands with jump links | | [What This Does](#what-this-project-does) | The problem and how this solves it | | [What's Different](#what-makes-this-different) | Why this isn't just another AI tool | | [Design Philosophy](#design-philosophy) | The thinking behind the architecture | @@ -34,8 +34,9 @@ AI models can write code. That's not the hard part anymore. The hard part is eve | [`/audit-project`](#audit-project) | Multi-agent code review until issues resolved | [→](#audit-project) | | [`/drift-detect`](#drift-detect) | Compares your docs to actual code state | [→](#drift-detect) | | [`/repo-map`](#repo-map) | Builds a cached AST repo map for fast analysis | [→](#repo-map) | -| [`/enhance`](#enhance) | Analyzes prompts, plugins, docs for improvements | [→](#enhance) | +| [`/enhance`](#enhance) | Analyzes prompts, plugins, agents, docs, hooks, skills | [→](#enhance) | | [`/sync-docs`](#sync-docs) | Syncs documentation with code changes | [→](#sync-docs) | +| [`/perf`](#perf) | Runs structured performance investigations | [→](#perf) | --- @@ -455,9 +456,9 @@ Tools like `/drift-detect` and planners can use the map instead of re-scanning t ### /enhance -**Purpose:** Analyzes your prompts, plugins, agents, and docs for improvement opportunities. +**Purpose:** Analyzes your prompts, plugins, agents, docs, hooks, and skills for improvement opportunities. -**Five analyzers run in parallel:** +**Seven analyzers run in parallel:** | Analyzer | What it checks | |----------|----------------| @@ -466,6 +467,8 @@ Tools like `/drift-detect` and planners can use the map instead of re-scanning t | claudemd-enhancer | CLAUDE.md/AGENTS.md structure, token efficiency | | docs-enhancer | Documentation readability, RAG optimization | | prompt-enhancer | Prompt engineering patterns, clarity, examples | +| hooks-enhancer | Hook frontmatter, structure, safety | +| skills-enhancer | SKILL.md structure, trigger phrases | **Each finding includes:** - Certainty level (HIGH/MEDIUM/LOW) @@ -484,6 +487,32 @@ Tools like `/drift-detect` and planners can use the map instead of re-scanning t --- +### /perf + +**Purpose:** Run structured performance investigations with baselines, profiling, and evidence‑backed decisions. + +**Usage:** + +```bash +/perf # Start new investigation +/perf --resume # Resume previous investigation +``` + +**Phase flags (advanced):** + +```bash +/perf --phase baseline --command "npm run bench" --version v1.2.0 +/perf --phase breaking-point --command "npm run bench" --param-min 1 --param-max 500 +/perf --phase constraints --command "npm run bench" --cpu 1 --memory 1GB +/perf --phase hypotheses --hypotheses-file perf-hypotheses.json +/perf --phase code-paths +/perf --phase optimization --change "reduce allocations" +/perf --phase decision --verdict stop --rationale "no measurable improvement" +/perf --phase consolidation --version v1.2.0 +``` + +--- + ### /sync-docs **Purpose:** Sync documentation with actual code changes—find outdated refs, update CHANGELOG, flag stale examples. diff --git a/__tests__/enhance-hooks-skills-analyzer.test.js b/__tests__/enhance-hooks-skills-analyzer.test.js new file mode 100644 index 00000000..a6d84454 --- /dev/null +++ b/__tests__/enhance-hooks-skills-analyzer.test.js @@ -0,0 +1,64 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { analyzeHook, analyzeAllHooks } = require('../lib/enhance/hook-analyzer'); +const { analyzeSkill, analyzeAllSkills } = require('../lib/enhance/skill-analyzer'); + +describe('enhance hook/skill analyzers', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'enhance-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test('analyzeHook detects missing frontmatter', () => { + const hookPath = path.join(tempDir, 'hooks'); + fs.mkdirSync(hookPath, { recursive: true }); + const filePath = path.join(hookPath, 'pre-commit.md'); + fs.writeFileSync(filePath, '# Hook\n\nNo frontmatter.'); + + const result = analyzeHook(filePath); + expect(result.structureIssues.length).toBeGreaterThanOrEqual(1); + expect(result.structureIssues.some(issue => /Missing YAML frontmatter/i.test(issue.issue))).toBe(true); + }); + + test('analyzeAllHooks scans nested hooks directories', () => { + const hookA = path.join(tempDir, 'plugins', 'alpha', 'hooks'); + const hookB = path.join(tempDir, 'tools', 'hooks'); + fs.mkdirSync(hookA, { recursive: true }); + fs.mkdirSync(hookB, { recursive: true }); + fs.writeFileSync(path.join(hookA, 'a.md'), '---\nname: a\ndescription: test\n---\n'); + fs.writeFileSync(path.join(hookB, 'b.md'), '---\nname: b\ndescription: test\n---\n'); + + const results = analyzeAllHooks(tempDir); + expect(results.length).toBe(2); + }); + + test('analyzeSkill detects missing trigger phrase', () => { + const skillDir = path.join(tempDir, 'skills', 'example'); + fs.mkdirSync(skillDir, { recursive: true }); + const filePath = path.join(skillDir, 'SKILL.md'); + fs.writeFileSync(filePath, '---\nname: example\ndescription: Helpful skill.\n---\n'); + + const result = analyzeSkill(filePath); + expect(result.triggerIssues.length).toBe(1); + expect(result.triggerIssues[0].issue).toMatch(/trigger phrase/i); + }); + + test('analyzeAllSkills finds nested SKILL.md files', () => { + const skillA = path.join(tempDir, 'skills', 'alpha'); + const skillB = path.join(tempDir, 'plugins', 'beta', 'skills', 'beta-skill'); + fs.mkdirSync(skillA, { recursive: true }); + fs.mkdirSync(skillB, { recursive: true }); + fs.writeFileSync(path.join(skillA, 'SKILL.md'), '---\nname: alpha\ndescription: Use when user asks about alpha.\n---\n'); + fs.writeFileSync(path.join(skillB, 'SKILL.md'), '---\nname: beta\ndescription: Use when user asks about beta.\n---\n'); + + const results = analyzeAllSkills(tempDir); + expect(results.length).toBe(2); + }); +}); diff --git a/__tests__/perf-argument-parser.test.js b/__tests__/perf-argument-parser.test.js new file mode 100644 index 00000000..440cd9b9 --- /dev/null +++ b/__tests__/perf-argument-parser.test.js @@ -0,0 +1,38 @@ +const { parseArguments } = require('../lib/perf/argument-parser'); + +describe('perf argument parser', () => { + it('handles empty input', () => { + expect(parseArguments('')).toEqual([]); + expect(parseArguments(' ')).toEqual([]); + expect(parseArguments(null)).toEqual([]); + }); + + it('splits basic arguments', () => { + expect(parseArguments('--phase baseline --version v1')).toEqual([ + '--phase', + 'baseline', + '--version', + 'v1' + ]); + }); + + it('preserves quoted values', () => { + const raw = '--command \"npm run bench -- --scenario small\" --quote \"latency spikes\"'; + expect(parseArguments(raw)).toEqual([ + '--command', + 'npm run bench -- --scenario small', + '--quote', + 'latency spikes' + ]); + }); + + it('supports single quotes', () => { + const raw = "--quote 'cache miss surge' --phase profiling"; + expect(parseArguments(raw)).toEqual([ + '--quote', + 'cache miss surge', + '--phase', + 'profiling' + ]); + }); +}); diff --git a/__tests__/perf-baseline.test.js b/__tests__/perf-baseline.test.js new file mode 100644 index 00000000..b9de7f2d --- /dev/null +++ b/__tests__/perf-baseline.test.js @@ -0,0 +1,42 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const stateDir = require('../lib/platform/state-dir'); +const baselineStore = require('../lib/perf/baseline-store'); +const baselineComparator = require('../lib/perf/baseline-comparator'); + +describe('perf baseline store', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'perf-baseline-')); + process.env.AI_STATE_DIR = '.ai-state'; + stateDir.clearCache(); + }); + + afterEach(() => { + stateDir.clearCache(); + delete process.env.AI_STATE_DIR; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('writes and reads baselines', () => { + baselineStore.writeBaseline('v1.0.0', { metrics: { latency: 120 }, command: 'npm run bench' }, tempDir); + const baseline = baselineStore.readBaseline('v1.0.0', tempDir); + expect(baseline).not.toBeNull(); + expect(baseline.metrics.latency).toBe(120); + }); + + it('rejects invalid baseline versions', () => { + expect(() => baselineStore.getBaselinePath('../v1.0.0', tempDir)).toThrow(); + }); + + it('compares baseline metrics', () => { + const result = baselineComparator.compareBaselines( + { metrics: { latency: 100 } }, + { metrics: { latency: 125 } } + ); + expect(result.metrics.latency.delta).toBe(25); + }); +}); diff --git a/__tests__/perf-benchmark-runner.test.js b/__tests__/perf-benchmark-runner.test.js new file mode 100644 index 00000000..5c491408 --- /dev/null +++ b/__tests__/perf-benchmark-runner.test.js @@ -0,0 +1,34 @@ +const { parseMetrics } = require('../lib/perf/benchmark-runner'); + +describe('perf benchmark parser', () => { + it('parses single scenario metrics', () => { + const output = [ + 'noise', + 'PERF_METRICS_START', + '{"latency_ms":120,"throughput_rps":450}', + 'PERF_METRICS_END', + 'tail' + ].join('\n'); + + const result = parseMetrics(output); + expect(result.ok).toBe(true); + expect(result.metrics.latency_ms).toBe(120); + }); + + it('parses multi-scenario metrics', () => { + const output = [ + 'PERF_METRICS_START', + '{"scenarios":{"low":{"latency_ms":120},"high":{"latency_ms":450}}}', + 'PERF_METRICS_END' + ].join('\n'); + + const result = parseMetrics(output); + expect(result.ok).toBe(true); + expect(result.metrics.scenarios.low.latency_ms).toBe(120); + }); + + it('fails when markers are missing', () => { + const result = parseMetrics('no metrics here'); + expect(result.ok).toBe(false); + }); +}); diff --git a/__tests__/perf-breaking-point-runner.test.js b/__tests__/perf-breaking-point-runner.test.js new file mode 100644 index 00000000..32a821c5 --- /dev/null +++ b/__tests__/perf-breaking-point-runner.test.js @@ -0,0 +1,19 @@ +const { runBreakingPointSearch } = require('../lib/perf/breaking-point-runner'); + +describe('perf breaking point runner', () => { + it('finds breaking point in synthetic range', async () => { + const originalEnv = process.env.PERF_PARAM_VALUE; + + const result = await runBreakingPointSearch({ + command: 'node -e "const v=parseInt(process.env.PERF_PARAM_VALUE||\'0\',10); if(v>=5){process.exit(1);} console.log(\'PERF_METRICS_START\\n{}\\nPERF_METRICS_END\');"', + paramEnv: 'PERF_PARAM_VALUE', + min: 1, + max: 8 + }); + + process.env.PERF_PARAM_VALUE = originalEnv; + + expect(result.attempts).toBeGreaterThan(0); + expect(result.breakingPoint).toBe(5); + }); +}); diff --git a/__tests__/perf-checkpoint.test.js b/__tests__/perf-checkpoint.test.js new file mode 100644 index 00000000..aa160e34 --- /dev/null +++ b/__tests__/perf-checkpoint.test.js @@ -0,0 +1,51 @@ +const checkpoint = require('../lib/perf/checkpoint'); + +describe('perf checkpoint', () => { + it('builds checkpoint message', () => { + const message = checkpoint.buildCheckpointMessage({ + phase: 'baseline', + id: 'perf-123', + baselineVersion: 'v1.0.0', + deltaSummary: 'latency -8%' + }); + + expect(message).toBe('perf: phase baseline [perf-123] baseline=v1.0.0 delta=latency -8%'); + }); + + it('handles no-op commits gracefully', () => { + const childProcess = require('child_process'); + const execSpy = jest.spyOn(childProcess, 'execSync').mockImplementation(() => { + throw new Error('not a git repo'); + }); + + const result = checkpoint.commitCheckpoint({ + phase: 'baseline', + id: 'perf-123' + }); + + if (!result.ok) { + expect(['not a git repo', 'nothing to commit', 'duplicate checkpoint']).toContain(result.reason); + } else { + expect(result.message).toContain('perf: phase baseline'); + } + + execSpy.mockRestore(); + }); + + it('detects duplicate checkpoint messages', () => { + jest.resetModules(); + jest.doMock('child_process', () => ({ + execSync: jest.fn(() => 'perf: phase baseline [perf-123] baseline=n/a delta=n/a\n'), + execFileSync: jest.fn() + })); + + const freshCheckpoint = require('../lib/perf/checkpoint'); + const message = freshCheckpoint.buildCheckpointMessage({ + phase: 'baseline', + id: 'perf-123' + }); + + expect(freshCheckpoint.isDuplicateCheckpoint(message)).toBe(true); + jest.dontMock('child_process'); + }); +}); diff --git a/__tests__/perf-code-paths.test.js b/__tests__/perf-code-paths.test.js new file mode 100644 index 00000000..466761e1 --- /dev/null +++ b/__tests__/perf-code-paths.test.js @@ -0,0 +1,43 @@ +const { normalizeKeywords, collectCodePaths } = require('../lib/perf/code-paths'); + +describe('perf code-paths', () => { + it('normalizes scenario keywords', () => { + expect(normalizeKeywords('Auth latency spikes during login')).toEqual([ + 'auth', + 'latency', + 'spikes', + 'during', + 'login' + ]); + }); + + it('collects code paths from repo map', () => { + const map = { + files: { + 'src/auth/login.js': { + symbols: { + exports: [], + functions: [{ name: 'login' }], + classes: [], + types: [], + constants: [] + } + }, + 'src/cache/index.js': { + symbols: { + exports: [], + functions: [{ name: 'warmCache' }], + classes: [], + types: [], + constants: [] + } + } + } + }; + + const result = collectCodePaths(map, 'Login latency regression', 5); + expect(result.keywords).toContain('login'); + expect(result.paths.length).toBe(1); + expect(result.paths[0].file).toBe('src/auth/login.js'); + }); +}); diff --git a/__tests__/perf-consolidation.test.js b/__tests__/perf-consolidation.test.js new file mode 100644 index 00000000..54b7e408 --- /dev/null +++ b/__tests__/perf-consolidation.test.js @@ -0,0 +1,35 @@ +const consolidation = require('../lib/perf/consolidation'); +const stateDir = require('../lib/platform/state-dir'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +describe('perf consolidation', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'perf-consolidation-')); + process.env.AI_STATE_DIR = '.ai-state'; + stateDir.clearCache(); + }); + + afterEach(() => { + stateDir.clearCache(); + delete process.env.AI_STATE_DIR; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('writes a single baseline per version', () => { + const result = consolidation.consolidateBaseline({ + version: 'v1.0.0', + baseline: { + command: 'npm run bench', + metrics: { latency_ms: 120 } + } + }, tempDir); + + expect(result.version).toBe('v1.0.0'); + const baselinePath = path.join(tempDir, '.ai-state', 'perf', 'baselines', 'v1.0.0.json'); + expect(fs.existsSync(baselinePath)).toBe(true); + }); +}); diff --git a/__tests__/perf-constraint-runner.test.js b/__tests__/perf-constraint-runner.test.js new file mode 100644 index 00000000..ee101d93 --- /dev/null +++ b/__tests__/perf-constraint-runner.test.js @@ -0,0 +1,15 @@ +const { runConstraintTest } = require('../lib/perf/constraint-runner'); + +describe('perf constraint runner', () => { + it('returns baseline, constrained, and delta metrics', () => { + const command = 'node -e "console.log(\'PERF_METRICS_START\'); console.log(JSON.stringify({latency_ms:120})); console.log(\'PERF_METRICS_END\');"'; + const result = runConstraintTest({ + command, + constraints: { cpu: '1', memory: '1GB' } + }); + + expect(result.baseline.metrics.latency_ms).toBe(120); + expect(result.constrained.metrics.latency_ms).toBe(120); + expect(result.delta.metrics.latency_ms.delta).toBe(0); + }); +}); diff --git a/__tests__/perf-log-helpers.test.js b/__tests__/perf-log-helpers.test.js new file mode 100644 index 00000000..99a7aa94 --- /dev/null +++ b/__tests__/perf-log-helpers.test.js @@ -0,0 +1,86 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const investigationState = require('../lib/perf/investigation-state'); +const stateDir = require('../lib/platform/state-dir'); + +describe('perf log helpers', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'perf-logs-')); + process.env.AI_STATE_DIR = '.ai-state'; + stateDir.clearCache(); + }); + + afterEach(() => { + stateDir.clearCache(); + delete process.env.AI_STATE_DIR; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('appends setup, breaking-point, constraints, hypotheses, code-paths, optimization logs', () => { + const state = investigationState.initializeInvestigation({ + scenario: 'Test scenario' + }, tempDir); + + investigationState.appendSetupLog({ + id: state.id, + userQuote: 'Run perf setup.', + scenario: 'Test scenario', + command: 'npm run bench', + version: 'v1.0.0' + }, tempDir); + + investigationState.appendBreakingPointLog({ + id: state.id, + userQuote: 'Find breaking point.', + paramEnv: 'PERF_PARAM_VALUE', + min: 1, + max: 10, + breakingPoint: 6 + }, tempDir); + + investigationState.appendConstraintLog({ + id: state.id, + userQuote: 'Test constraints.', + constraints: { cpu: '1', memory: '1GB' }, + delta: { metrics: { latency_ms: { delta: 10 } } } + }, tempDir); + + investigationState.appendHypothesesLog({ + id: state.id, + userQuote: 'Generate hypotheses.', + hypotheses: [ + { id: 'H1', hypothesis: 'N+1 queries', evidence: 'src/db.js', confidence: 'medium' } + ] + }, tempDir); + + investigationState.appendCodePathsLog({ + id: state.id, + userQuote: 'Map code paths.', + keywords: ['auth', 'session'], + paths: [ + { file: 'src/auth/index.js', score: 2, symbols: ['login', 'logout'] } + ] + }, tempDir); + + investigationState.appendOptimizationLog({ + id: state.id, + userQuote: 'Try optimization.', + change: 'reduce allocations', + delta: { metrics: { latency_ms: { delta: -5 } } }, + verdict: 'inconclusive' + }, tempDir); + + const logPath = investigationState.getInvestigationLogPath(state.id, tempDir); + const contents = fs.readFileSync(logPath, 'utf8'); + expect(contents).toContain('Setup -'); + expect(contents).toContain('Breaking Point -'); + expect(contents).toContain('Constraints -'); + expect(contents).toContain('Hypotheses -'); + expect(contents).toContain('Code Paths -'); + expect(contents).toContain('Optimization -'); + }); +}); diff --git a/__tests__/perf-optimization-runner.test.js b/__tests__/perf-optimization-runner.test.js new file mode 100644 index 00000000..bd72c00d --- /dev/null +++ b/__tests__/perf-optimization-runner.test.js @@ -0,0 +1,16 @@ +const { runOptimizationExperiment } = require('../lib/perf/optimization-runner'); + +describe('perf optimization runner', () => { + it('runs experiment and returns delta', () => { + const command = 'node -e "console.log(\'PERF_METRICS_START\'); console.log(JSON.stringify({latency_ms:120})); console.log(\'PERF_METRICS_END\');"'; + const result = runOptimizationExperiment({ + command, + changeSummary: 'noop change', + requireClean: false + }); + + expect(result.baseline.metrics.latency_ms).toBe(120); + expect(result.experiment.metrics.latency_ms).toBe(120); + expect(result.delta.metrics.latency_ms.delta).toBe(0); + }); +}); diff --git a/__tests__/perf-profilers.test.js b/__tests__/perf-profilers.test.js new file mode 100644 index 00000000..c4f2a42b --- /dev/null +++ b/__tests__/perf-profilers.test.js @@ -0,0 +1,47 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const profilers = require('../lib/perf/profilers'); + +describe('perf profiler selection', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'perf-profilers-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('selects node profiler for package.json', () => { + fs.writeFileSync(path.join(tempDir, 'package.json'), '{}', 'utf8'); + const profiler = profilers.selectProfiler(tempDir); + expect(profiler.id).toBe('node'); + }); + + it('selects java profiler when pom.xml exists', () => { + fs.writeFileSync(path.join(tempDir, 'pom.xml'), '', 'utf8'); + const profiler = profilers.selectProfiler(tempDir); + expect(profiler.id).toBe('jfr'); + }); + + it('selects go profiler when go.mod exists', () => { + fs.writeFileSync(path.join(tempDir, 'go.mod'), 'module test', 'utf8'); + const profiler = profilers.selectProfiler(tempDir); + expect(profiler.id).toBe('pprof'); + }); + + it('selects python profiler when requirements.txt exists', () => { + fs.writeFileSync(path.join(tempDir, 'requirements.txt'), 'flask', 'utf8'); + const profiler = profilers.selectProfiler(tempDir); + expect(profiler.id).toBe('cprofile'); + }); + + it('selects rust profiler when Cargo.toml exists', () => { + fs.writeFileSync(path.join(tempDir, 'Cargo.toml'), '[package]', 'utf8'); + const profiler = profilers.selectProfiler(tempDir); + expect(profiler.id).toBe('perf'); + }); +}); diff --git a/__tests__/perf-profiling-runner.test.js b/__tests__/perf-profiling-runner.test.js new file mode 100644 index 00000000..10f9bdab --- /dev/null +++ b/__tests__/perf-profiling-runner.test.js @@ -0,0 +1,20 @@ +const profilingRunner = require('../lib/perf/profiling-runner'); +const profilers = require('../lib/perf/profilers'); + +describe('perf profiling runner', () => { + it('runs selected profiler command', () => { + const originalSelect = profilers.selectProfiler; + profilers.selectProfiler = () => ({ + id: 'fake', + buildCommand: () => 'node -e "console.log(\'ok\')"', + parseOutput: () => ({ tool: 'fake', hotspots: ['file:1'], artifacts: ['out.prof'] }) + }); + + const result = profilingRunner.runProfiling(); + profilers.selectProfiler = originalSelect; + + expect(result.ok).toBe(true); + expect(result.result.tool).toBe('fake'); + expect(result.result.artifacts[0]).toBe('out.prof'); + }); +}); diff --git a/__tests__/perf-schemas.test.js b/__tests__/perf-schemas.test.js new file mode 100644 index 00000000..35fb369c --- /dev/null +++ b/__tests__/perf-schemas.test.js @@ -0,0 +1,50 @@ +const { validateBaseline, validateInvestigationState } = require('../lib/perf/schemas'); + +describe('perf schemas', () => { + it('flags invalid baseline metrics', () => { + const result = validateBaseline({ + version: 'v1.0.0', + recordedAt: new Date().toISOString(), + command: 'npm run bench', + metrics: { latency: 'slow' } + }); + expect(result.ok).toBe(false); + expect(result.errors.join(' ')).toContain('metric latency'); + }); + + it('accepts minimal valid baseline', () => { + const result = validateBaseline({ + version: 'v1.0.0', + recordedAt: new Date().toISOString(), + command: 'npm run bench', + metrics: { latency: 120 } + }); + expect(result.ok).toBe(true); + }); + + it('accepts multi-scenario baseline metrics', () => { + const result = validateBaseline({ + version: 'v1.0.0', + recordedAt: new Date().toISOString(), + command: 'npm run bench', + metrics: { + scenarios: { + low: { latency_ms: 120 }, + high: { latency_ms: 450 } + } + } + }); + expect(result.ok).toBe(true); + }); + + it('flags invalid investigation state', () => { + const result = validateInvestigationState({ + schemaVersion: 1, + id: '', + status: 'in_progress', + phase: '', + scenario: {} + }); + expect(result.ok).toBe(false); + }); +}); diff --git a/__tests__/perf-state.test.js b/__tests__/perf-state.test.js new file mode 100644 index 00000000..9f78e48b --- /dev/null +++ b/__tests__/perf-state.test.js @@ -0,0 +1,109 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const stateDir = require('../lib/platform/state-dir'); +const investigationState = require('../lib/perf/investigation-state'); + +describe('perf investigation state', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'perf-state-')); + process.env.AI_STATE_DIR = '.ai-state'; + stateDir.clearCache(); + }); + + afterEach(() => { + stateDir.clearCache(); + delete process.env.AI_STATE_DIR; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('creates and reads investigation state', () => { + const state = investigationState.initializeInvestigation({ + scenario: 'API latency spike' + }, tempDir); + + const readBack = investigationState.readInvestigation(tempDir); + expect(readBack).not.toBeNull(); + expect(readBack.id).toBe(state.id); + expect(readBack.scenario.description).toBe('API latency spike'); + }); + + it('appends investigation log entries', () => { + const state = investigationState.initializeInvestigation({}, tempDir); + investigationState.appendInvestigationLog(state.id, 'Entry 1', tempDir); + investigationState.appendInvestigationLog(state.id, 'Entry 2', tempDir); + + const logPath = investigationState.getInvestigationLogPath(state.id, tempDir); + const contents = fs.readFileSync(logPath, 'utf8'); + expect(contents).toContain('Entry 1'); + expect(contents).toContain('Entry 2'); + }); + + it('rejects invalid investigation ids', () => { + expect(() => investigationState.getInvestigationLogPath('../bad-id', tempDir)).toThrow(); + }); + + it('appends baseline log entries', () => { + const state = investigationState.initializeInvestigation({ + scenarios: [ + { name: 'low', params: { concurrency: 10 } }, + { name: 'high', params: { concurrency: 200 } } + ] + }, tempDir); + const baselinePath = path.join(tempDir, '.ai-state', 'perf', 'baselines', 'v1.0.0.json'); + + investigationState.appendBaselineLog({ + id: state.id, + userQuote: 'Baseline the API latency.', + command: 'npm run bench', + metrics: { latency_ms: 120 }, + baselinePath, + scenarios: state.scenario.scenarios + }, tempDir); + + const logPath = investigationState.getInvestigationLogPath(state.id, tempDir); + const contents = fs.readFileSync(logPath, 'utf8'); + expect(contents).toContain('Baseline -'); + expect(contents).toContain('Baseline the API latency.'); + expect(contents).toContain('npm run bench'); + expect(contents).toContain('latency_ms'); + expect(contents).toContain(baselinePath); + }); + + it('appends profiling log entries', () => { + const state = investigationState.initializeInvestigation({}, tempDir); + investigationState.appendProfilingLog({ + id: state.id, + userQuote: 'Profile the hot path.', + tool: 'jfr', + command: 'java -XX:StartFlightRecording=duration=60s,filename=profile.jfr', + artifacts: ['profile.jfr'], + hotspots: ['src/App.java:42'] + }, tempDir); + + const logPath = investigationState.getInvestigationLogPath(state.id, tempDir); + const contents = fs.readFileSync(logPath, 'utf8'); + expect(contents).toContain('Profiling -'); + expect(contents).toContain('jfr'); + expect(contents).toContain('profile.jfr'); + }); + + it('appends decision log entries', () => { + const state = investigationState.initializeInvestigation({}, tempDir); + investigationState.appendDecisionLog({ + id: state.id, + userQuote: 'Stop if improvement is negligible.', + verdict: 'stop', + rationale: 'No measurable improvement after 3 experiments.' + }, tempDir); + + const logPath = investigationState.getInvestigationLogPath(state.id, tempDir); + const contents = fs.readFileSync(logPath, 'utf8'); + expect(contents).toContain('Decision -'); + expect(contents).toContain('Verdict: stop'); + expect(contents).toContain('No measurable improvement'); + }); +}); diff --git a/adapters/opencode-plugin/index.ts b/adapters/opencode-plugin/index.ts index 5d015e15..bc3fb5ae 100644 --- a/adapters/opencode-plugin/index.ts +++ b/adapters/opencode-plugin/index.ts @@ -45,6 +45,8 @@ const AGENT_THINKING_CONFIG: Record **MCP is optional.** If you're running awesome-slash as native plugins/skills (Claude Code, OpenCode, Codex CLI) or invoking scripts directly, you can skip MCP. Use MCP when you want a generic tool endpoint for external clients. +## Command Arguments ($ARGUMENTS) + +Claude Code passes a raw `$ARGUMENTS` string into commands. Commands should parse the raw string locally (including quoted values) to match Claude Code behavior. + +For OpenCode and Codex CLI, the installer adapts the platform argument handling to preserve `$ARGUMENTS` as a raw string. This keeps parsing consistent across platforms without changing command content. + ## Claude Code (Native) ### Option 1: Marketplace (Recommended) @@ -94,7 +100,7 @@ claude --plugin-dir /path/to/awesome-slash/plugins/next-task | ci-fixer | sonnet | Fix CI failures and PR comments | | simple-fixer | haiku | Execute pre-defined fixes | -**enhance: Quality Analyzers (7 agents)** +**enhance: Quality Analyzers (9 agents)** | Agent | Model | Purpose | |-------|-------|---------| @@ -104,6 +110,8 @@ claude --plugin-dir /path/to/awesome-slash/plugins/next-task | docs-enhancer | opus | Documentation quality | | claudemd-enhancer | opus | Project memory optimization | | prompt-enhancer | opus | General prompt quality | +| hooks-enhancer | sonnet | Hook frontmatter and safety | +| skills-enhancer | sonnet | SKILL.md structure and triggers | | enhancement-reporter | sonnet | Format unified reports | **drift-detect: Drift Detection (1 agent)** @@ -286,7 +294,7 @@ When using the MCP server integration, these tools become available: | `task_discover` | Find and prioritize tasks from gh-issues, linear, or tasks-md | | `review_code` | Run pattern-based code review on changed files | | `slop_detect` | Detect AI slop with certainty levels (HIGH/MEDIUM/LOW) | -| `enhance_analyze` | Analyze plugins, agents, docs, prompts for improvements | +| `enhance_analyze` | Analyze plugins, agents, docs, prompts, hooks, skills | | `repo_map` | Generate or update cached AST repo map | ## Shared Libraries diff --git a/docs/README.md b/docs/README.md index 1c9b19b2..2e01131a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,7 @@ AI models can write code. The bottleneck is everything else—picking tasks, man | See examples and workflows | [USAGE.md](./USAGE.md) | | Understand how /next-task works | [workflows/NEXT-TASK.md](./workflows/NEXT-TASK.md) | | Understand how /ship works | [workflows/SHIP.md](./workflows/SHIP.md) | +| Run /perf investigations | [perf-requirements.md](./perf-requirements.md) | | Use with OpenCode or Codex | [CROSS_PLATFORM.md](./CROSS_PLATFORM.md) | | See all slop patterns | [reference/SLOP-PATTERNS.md](./reference/SLOP-PATTERNS.md) | | See all agents | [reference/AGENTS.md](./reference/AGENTS.md) | @@ -37,12 +38,14 @@ AI models can write code. The bottleneck is everything else—picking tasks, man |----------|-------------| | [workflows/NEXT-TASK.md](./workflows/NEXT-TASK.md) | Complete /next-task flow: phases, agents, state management, resume. | | [workflows/SHIP.md](./workflows/SHIP.md) | Complete /ship flow: CI monitoring, review handling, merge, deploy. | +| [perf-requirements.md](./perf-requirements.md) | /perf rules and required phases. | +| [perf-research-methodology.md](./perf-research-methodology.md) | /perf process details, benchmarking method. | ### Reference | Document | Description | |----------|-------------| -| [reference/AGENTS.md](./reference/AGENTS.md) | All 31 agents: purpose, model, tools, restrictions. | +| [reference/AGENTS.md](./reference/AGENTS.md) | All 39 agents: purpose, model, tools, restrictions. | | [reference/SLOP-PATTERNS.md](./reference/SLOP-PATTERNS.md) | All detection patterns by language, severity, auto-fix. | | [reference/MCP-TOOLS.md](./reference/MCP-TOOLS.md) | MCP server tools: parameters, returns, platform config. | @@ -67,7 +70,8 @@ AI models can write code. The bottleneck is everything else—picking tasks, man | `/audit-project` | Multi-agent code review | | `/drift-detect` | Compare docs to actual code | | `/repo-map` | Build cached AST repo map | -| `/enhance` | Analyze prompts, plugins, docs | +| `/perf` | Performance investigation workflow | +| `/enhance` | Analyze prompts, plugins, agents, docs, hooks, skills | | `/sync-docs` | Sync docs with code changes | ### Internal Skills diff --git a/docs/USAGE.md b/docs/USAGE.md index 197040a5..2aea58d7 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -29,8 +29,9 @@ You shouldn't have to repeat the same requests every session. These commands han | `/audit-project` | Multi-agent code review | Thorough analysis | | `/drift-detect` | Compare docs to actual code | Plan drift detection | | `/repo-map` | Build cached AST repo map | Faster analysis & symbol lookup | -| `/enhance` | Analyze prompts, plugins, docs | Quality improvement | +| `/enhance` | Analyze prompts, plugins, agents, docs, hooks, skills | Quality improvement | | `/sync-docs` | Sync docs with code changes | Documentation sync | +| `/perf` | Performance investigation workflow | Baselines, profiling, evidence | --- @@ -190,6 +191,30 @@ Sync documentation with actual code state. Find outdated references, update CHAN --- +### `/perf` + +Structured performance investigation with baselines, profiling, and evidence‑backed decisions. + +```bash +/perf # Start new investigation +/perf --resume # Resume previous investigation +``` + +**Phase flags (advanced):** + +```bash +/perf --phase baseline --command "npm run bench" --version v1.2.0 +/perf --phase breaking-point --command "npm run bench" --param-min 1 --param-max 500 +/perf --phase constraints --command "npm run bench" --cpu 1 --memory 1GB +/perf --phase hypotheses --hypotheses-file perf-hypotheses.json +/perf --phase code-paths +/perf --phase optimization --change "reduce allocations" +/perf --phase decision --verdict stop --rationale "no measurable improvement" +/perf --phase consolidation --version v1.2.0 +``` + +--- + ### `/ship` Complete PR workflow from commit to production. diff --git a/docs/perf-requirements.md b/docs/perf-requirements.md new file mode 100644 index 00000000..afc6cc71 --- /dev/null +++ b/docs/perf-requirements.md @@ -0,0 +1,61 @@ +# Performance Investigation Requirements + +This is the canonical contract for the /perf workflow. All agents, skills, hooks, and commands must follow these rules. + +## Non-Negotiable Rules + +1. Run benchmarks sequentially (never in parallel). +2. Minimum run duration is 60s (30s only for binary search in breaking-point phase). +3. Change one thing at a time; revert to baseline between experiments. +4. Start narrow and expand only with explicit user approval. +5. Verify anomalies by re-running. +6. Establish a clean baseline before any experiment. +7. Keep resource use minimal and repeatable. +8. Check git history before hypotheses or changes. +9. Clarify terminology before acting on ambiguous requests. +10. Write logs + checkpoint commit after every phase. + +## Required Phases + +1. Setup and clarification +2. Baseline establishment +3. Breaking point discovery (binary search) +4. Constraint testing (CPU/memory limits) +5. Hypothesis generation +6. Code-path analysis +7. Profiling (CPU/memory/JFR/perf) +8. Optimization experiments +9. Decision point (continue/stop) +10. Consolidation + +## Evidence Requirements + +Every phase log must include: +- Exact user quote (verbatim) +- Phase summary +- Evidence pointers (commands, files, metrics) +- Decision and rationale (when applicable) + +## Baseline Requirements + +- Baseline command must output PERF_METRICS markers. +- Baseline JSON is stored at {state-dir}/perf/baselines/.json. +- Baseline metrics must be numeric and comparable across runs. + +## State Requirements + +All perf state is stored under {state-dir}/perf/: +- investigation.json +- investigations/.md +- baselines/.json + +State directory is platform-specific: +- Claude Code: .claude/ +- OpenCode: .opencode/ +- Codex CLI: .codex/ + +## Scope Boundaries + +- Only supported languages: Rust, Java, JavaScript, TypeScript, Go, Python. +- Use repo-map and grep for code-path analysis before profiling. +- Profiling artifacts must be captured and referenced in logs. diff --git a/docs/perf-research-methodology.md b/docs/perf-research-methodology.md new file mode 100644 index 00000000..b13f658e --- /dev/null +++ b/docs/perf-research-methodology.md @@ -0,0 +1,85 @@ +# Performance Research Methodology + +This document defines how /perf investigations are executed. It complements perf-requirements.md with process detail. + +## 1. Setup + +- Confirm scenario, success criteria, and benchmark command. +- Capture the user quote verbatim. +- Record version label for the baseline. + +## 2. Baseline + +- Run the benchmark for at least 60s. +- Require PERF_METRICS markers in output. +- Parse metrics and store baseline JSON. +- Re-run if results look anomalous. + +## 3. Breaking Point + +- Use binary search with 30s runs. +- Parameterize via PERF_PARAM_VALUE (or configured env). +- Record the smallest value that fails or degrades beyond thresholds. + +## 4. Constraints + +- Apply CPU/memory limits (default CPU=1, memory=1GB). +- Measure delta vs baseline and log constraints + deltas. + +## 5. Hypotheses + +- Read recent git history and relevant code paths. +- Produce up to 5 hypotheses with evidence and confidence. +- No optimization changes in this phase. + +## 6. Code Paths + +- Use repo-map to identify entrypoints, handlers, and data access layers. +- List top candidate files/symbols for profiling focus. +- Record imports/exports when relevant to show wiring. + +## 7. Profiling + +- Prefer built-in tools for each language: + - Node: --cpu-prof + - Java: JFR + - Python: cProfile + - Go: pprof + - Rust: perf +- Capture artifacts and hotspots; log file:line evidence. + +## 8. Optimization + +- One change per experiment. +- Run 2+ validation passes per change. +- Revert to baseline before next change. + +## 9. Decision + +- If improvement is not measurable, recommend stop. +- If improvement exists, document next changes to pursue. + +## 10. Consolidation + +- Consolidate final baseline and log evidence. +- Mark investigation complete. + +## Benchmarks Output Format + +Benchmarks must output PERF_METRICS markers, e.g.: + +``` +PERF_METRICS latency_ms=120.5 throughput_rps=2400 +``` + +Scenario-specific metrics can be emitted as: + +``` +PERF_METRICS scenario=checkout latency_ms=180.1 +``` + +## Noise Handling + +- Re-run if deviation >5% without clear cause. +- Log anomalies and retest before recording results. +- Keep environment stable (no background tasks, same config). diff --git a/docs/reference/AGENTS.md b/docs/reference/AGENTS.md index f0bb4b11..21064af4 100644 --- a/docs/reference/AGENTS.md +++ b/docs/reference/AGENTS.md @@ -2,7 +2,7 @@ Complete reference for all agents in awesome-slash. -**TL;DR:** 31 agents across 5 plugins. opus for reasoning, sonnet for patterns, haiku for execution. Each agent does one thing well. +**TL;DR:** 39 agents across 6 plugins. opus for reasoning, sonnet for patterns, haiku for execution. Each agent does one thing well. --- @@ -12,9 +12,10 @@ Complete reference for all agents in awesome-slash. |--------|--------|---------| | next-task | 12 | [task-discoverer](#task-discoverer), [worktree-manager](#worktree-manager), [exploration-agent](#exploration-agent), [planning-agent](#planning-agent), [implementation-agent](#implementation-agent), [deslop-work](#deslop-work), [test-coverage-checker](#test-coverage-checker), [delivery-validator](#delivery-validator), [docs-updater](#docs-updater), [simple-fixer](#simple-fixer), [ci-monitor](#ci-monitor), [ci-fixer](#ci-fixer) | | audit-project | 10 | [code-quality-reviewer](#code-quality-reviewer), [security-expert](#security-expert), [performance-engineer](#performance-engineer), [test-quality-guardian](#test-quality-guardian), [architecture-reviewer](#architecture-reviewer), [database-specialist](#database-specialist), [api-designer](#api-designer), [frontend-specialist](#frontend-specialist), [backend-specialist](#backend-specialist), [devops-reviewer](#devops-reviewer) | -| enhance | 7 | [enhancement-orchestrator](#enhancement-orchestrator), [plugin-enhancer](#plugin-enhancer), [agent-enhancer](#agent-enhancer), [claudemd-enhancer](#claudemd-enhancer), [docs-enhancer](#docs-enhancer), [prompt-enhancer](#prompt-enhancer), [enhancement-reporter](#enhancement-reporter) | +| enhance | 9 | [enhancement-orchestrator](#enhancement-orchestrator), [plugin-enhancer](#plugin-enhancer), [agent-enhancer](#agent-enhancer), [claudemd-enhancer](#claudemd-enhancer), [docs-enhancer](#docs-enhancer), [prompt-enhancer](#prompt-enhancer), [hooks-enhancer](#hooks-enhancer), [skills-enhancer](#skills-enhancer), [enhancement-reporter](#enhancement-reporter) | | drift-detect | 1 | [plan-synthesizer](#plan-synthesizer) | | repo-map | 1 | [map-validator](#map-validator) | +| perf | 6 | [perf-orchestrator](#perf-orchestrator), [perf-theory-gatherer](#perf-theory-gatherer), [perf-theory-tester](#perf-theory-tester), [perf-code-paths](#perf-code-paths), [perf-investigation-logger](#perf-investigation-logger), [perf-analyzer](#perf-analyzer) | **Design principle:** Each agent has a single responsibility. Complex work is decomposed into specialized agents that do one thing extremely well, then orchestrated together. @@ -26,7 +27,7 @@ Complete reference for all agents in awesome-slash. ## Overview -awesome-slash uses 31 specialized agents across 5 plugins. Each agent is optimized for a specific task and assigned a model based on complexity: +awesome-slash uses 39 specialized agents across 6 plugins. Each agent is optimized for a specific task and assigned a model based on complexity: | Model | Use Case | Cost | |-------|----------|------| @@ -35,7 +36,7 @@ awesome-slash uses 31 specialized agents across 5 plugins. Each agent is optimiz | haiku | Mechanical execution, no judgment | Low | **Agent types:** -- **File-based agents** (21) - Defined in `plugins/*/agents/*.md` with frontmatter +- **File-based agents** (29) - Defined in `plugins/*/agents/*.md` with frontmatter - **Role-based agents** (10) - Defined inline via Task tool with specialized prompts --- @@ -419,6 +420,36 @@ awesome-slash uses 31 specialized agents across 5 plugins. Each agent is optimiz --- +### hooks-enhancer + +**Model:** sonnet +**Purpose:** Analyze hook definitions. + +**Checks:** +- Frontmatter presence and structure +- Required name/description fields +- Basic formatting expectations + +**Tools available:** +- Read, Glob, Grep + +--- + +### skills-enhancer + +**Model:** sonnet +**Purpose:** Analyze SKILL.md quality. + +**Checks:** +- Frontmatter presence and structure +- Required name/description fields +- Trigger phrase clarity ("Use when user asks") + +**Tools available:** +- Read, Glob, Grep + +--- + ### enhancement-reporter **Model:** sonnet @@ -479,6 +510,73 @@ awesome-slash uses 31 specialized agents across 5 plugins. Each agent is optimiz --- +## perf Plugin Agents + +### perf-orchestrator + +**Model:** opus +**Purpose:** Coordinate /perf investigations across all phases. + +**What it does:** +1. Enforces perf rules and phase order +2. Spawns theory, profiling, and logging helpers +3. Ensures checkpoints + evidence after each phase + +**Tools available:** +- Read, Write, Edit, Task, Bash(git:*), Bash(npm:*), Bash(cargo:*), Bash(go:*), Bash(pytest:*), Bash(mvn:*), Bash(gradle:*) + +--- + +### perf-theory-gatherer + +**Model:** opus +**Purpose:** Generate hypotheses based on git history and evidence. + +**Tools available:** +- Read, Bash(git:*), Bash(npm:*), Bash(pnpm:*), Bash(yarn:*), Bash(cargo:*), Bash(go:*), Bash(pytest:*), Bash(python:*), Bash(mvn:*), Bash(gradle:*) + +--- + +### perf-theory-tester + +**Model:** opus +**Purpose:** Validate hypotheses with controlled experiments. + +**Tools available:** +- Read, Write, Edit, Bash(git:*), Bash(npm:*), Bash(pnpm:*), Bash(yarn:*), Bash(cargo:*), Bash(go:*), Bash(pytest:*), Bash(python:*), Bash(mvn:*), Bash(gradle:*) + +--- + +### perf-code-paths + +**Model:** sonnet +**Purpose:** Map entrypoints and likely hot files before profiling. + +**Tools available:** +- Read, Grep, Glob + +--- + +### perf-investigation-logger + +**Model:** sonnet +**Purpose:** Append structured investigation logs with evidence. + +**Tools available:** +- Read, Write + +--- + +### perf-analyzer + +**Model:** opus +**Purpose:** Synthesize findings into evidence-backed recommendations. + +**Tools available:** +- Read, Write + +--- + ## audit-project Plugin Agents These are role-based agents invoked via Task tool with specialized prompts. They use the built-in review subagent type with domain-specific instructions. diff --git a/docs/reference/MCP-TOOLS.md b/docs/reference/MCP-TOOLS.md index 900413b8..4f6b81a8 100644 --- a/docs/reference/MCP-TOOLS.md +++ b/docs/reference/MCP-TOOLS.md @@ -254,14 +254,14 @@ Detect AI slop patterns with certainty-based findings. ### enhance_analyze -Analyze plugins, agents, docs, or prompts for enhancement opportunities. +Analyze plugins, agents, docs, prompts, hooks, or skills for enhancement opportunities. **Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | path | string | No | Directory to analyze (default: current directory) | -| focus | string | No | Which analyzer: `all`, `plugin`, `agent`, `docs`, `claudemd`, `prompt` | +| focus | string | No | Which analyzer: `all`, `plugin`, `agent`, `docs`, `claudemd`, `claude-memory`, `prompt`, `hooks`, `skills` | | mode | string | No | `report` (default) or `apply` HIGH certainty fixes | | compact | boolean | No | Use compact output format (default: true) | diff --git a/lib/enhance/hook-analyzer.js b/lib/enhance/hook-analyzer.js new file mode 100644 index 00000000..2530e111 --- /dev/null +++ b/lib/enhance/hook-analyzer.js @@ -0,0 +1,135 @@ +/** + * Hook analyzer for /enhance. + */ + +const fs = require('fs'); +const path = require('path'); +const { hookPatterns } = require('./hook-patterns'); +const { parseMarkdownFrontmatter } = require('./agent-analyzer'); + +function analyzeHook(hookPath) { + const results = { + hookName: path.basename(hookPath, '.md'), + hookPath, + structureIssues: [] + }; + + if (!fs.existsSync(hookPath)) { + results.structureIssues.push({ + issue: 'File not found', + file: hookPath, + certainty: 'HIGH', + patternId: 'file_not_found' + }); + return results; + } + + let content = ''; + try { + content = fs.readFileSync(hookPath, 'utf8'); + } catch (err) { + results.structureIssues.push({ + issue: `Failed to read file: ${err.message}`, + file: hookPath, + certainty: 'HIGH', + patternId: 'read_error' + }); + return results; + } + + const missingFm = hookPatterns.missing_frontmatter.check(content); + if (missingFm) { + results.structureIssues.push({ + ...missingFm, + file: hookPath, + certainty: hookPatterns.missing_frontmatter.certainty, + patternId: hookPatterns.missing_frontmatter.id + }); + } + + const { frontmatter } = parseMarkdownFrontmatter(content); + const missingName = hookPatterns.missing_name.check(frontmatter); + if (missingName) { + results.structureIssues.push({ + ...missingName, + file: hookPath, + certainty: hookPatterns.missing_name.certainty, + patternId: hookPatterns.missing_name.id + }); + } + + const missingDescription = hookPatterns.missing_description.check(frontmatter); + if (missingDescription) { + results.structureIssues.push({ + ...missingDescription, + file: hookPath, + certainty: hookPatterns.missing_description.certainty, + patternId: hookPatterns.missing_description.id + }); + } + + return results; +} + +function analyzeAllHooks(hooksDir) { + const results = []; + if (!fs.existsSync(hooksDir)) return results; + + const hookFiles = []; + const skipDirs = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'target']); + + function walk(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (err) { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!skipDirs.has(entry.name)) { + walk(fullPath); + } + continue; + } + + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + const parts = fullPath.split(path.sep); + if (parts.includes('hooks')) { + hookFiles.push(fullPath); + } + } + } + + walk(hooksDir); + + for (const file of hookFiles) { + results.push(analyzeHook(file)); + } + + return results; +} + +function analyze(options = {}) { + const { + hook, + hooksDir = 'plugins/enhance/hooks' + } = options; + + if (hook) { + const hookPath = hook.endsWith('.md') + ? hook + : path.join(hooksDir, `${hook}.md`); + return analyzeHook(hookPath); + } + + return analyzeAllHooks(hooksDir); +} + +module.exports = { + analyzeHook, + analyzeAllHooks, + analyze +}; diff --git a/lib/enhance/hook-patterns.js b/lib/enhance/hook-patterns.js new file mode 100644 index 00000000..472c789b --- /dev/null +++ b/lib/enhance/hook-patterns.js @@ -0,0 +1,40 @@ +/** + * Hook patterns for /enhance. + */ + +const hookPatterns = { + missing_frontmatter: { + id: 'missing_frontmatter', + certainty: 'HIGH', + check(content) { + if (!content || !content.trim().startsWith('---')) { + return { issue: 'Missing YAML frontmatter in hook file' }; + } + return null; + } + }, + missing_name: { + id: 'missing_name', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.name) { + return { issue: 'Missing name in hook frontmatter' }; + } + return null; + } + }, + missing_description: { + id: 'missing_description', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.description) { + return { issue: 'Missing description in hook frontmatter' }; + } + return null; + } + } +}; + +module.exports = { + hookPatterns +}; diff --git a/lib/enhance/index.js b/lib/enhance/index.js index 542e81fd..07539241 100644 --- a/lib/enhance/index.js +++ b/lib/enhance/index.js @@ -16,6 +16,8 @@ const projectmemoryAnalyzer = require('./projectmemory-analyzer'); const projectmemoryPatterns = require('./projectmemory-patterns'); const promptAnalyzer = require('./prompt-analyzer'); const promptPatterns = require('./prompt-patterns'); +const hookAnalyzer = require('./hook-analyzer'); +const skillAnalyzer = require('./skill-analyzer'); const reporter = require('./reporter'); const fixer = require('./fixer'); @@ -26,6 +28,8 @@ module.exports = { docsAnalyzer, projectmemoryAnalyzer, promptAnalyzer, + hookAnalyzer, + skillAnalyzer, // Pattern modules pluginPatterns, @@ -72,6 +76,16 @@ module.exports = { promptApplyFixes: promptAnalyzer.applyFixes, promptGenerateReport: promptAnalyzer.generateReport, + // Convenience exports - Hooks + analyzeHook: hookAnalyzer.analyzeHook, + analyzeAllHooks: hookAnalyzer.analyzeAllHooks, + hooksAnalyze: hookAnalyzer.analyze, + + // Convenience exports - Skills + analyzeSkill: skillAnalyzer.analyzeSkill, + analyzeAllSkills: skillAnalyzer.analyzeAllSkills, + skillsAnalyze: skillAnalyzer.analyze, + // Convenience exports - Orchestrator generateOrchestratorReport: reporter.generateOrchestratorReport, deduplicateOrchestratorFindings: reporter.deduplicateOrchestratorFindings diff --git a/lib/enhance/reporter.js b/lib/enhance/reporter.js index 7016a1f8..77b727c6 100644 --- a/lib/enhance/reporter.js +++ b/lib/enhance/reporter.js @@ -1091,7 +1091,7 @@ function generateOrchestratorReport(aggregatedResults, options = {}) { lines.push('| Enhancer | HIGH | MEDIUM | LOW | Auto-Fixable |'); lines.push('|----------|------|--------|-----|--------------|'); - const enhancerTypes = ['plugin', 'agent', 'claudemd', 'docs', 'prompt']; + const enhancerTypes = ['plugin', 'agent', 'claudemd', 'docs', 'prompt', 'hooks', 'skills']; let totalHigh = 0, totalMedium = 0, totalLow = 0, totalAutoFix = 0; for (const enhancer of enhancerTypes) { diff --git a/lib/enhance/skill-analyzer.js b/lib/enhance/skill-analyzer.js new file mode 100644 index 00000000..023ac494 --- /dev/null +++ b/lib/enhance/skill-analyzer.js @@ -0,0 +1,144 @@ +/** + * Skill analyzer for /enhance. + */ + +const fs = require('fs'); +const path = require('path'); +const { skillPatterns } = require('./skill-patterns'); +const { parseMarkdownFrontmatter } = require('./agent-analyzer'); + +function analyzeSkill(skillPath) { + const results = { + skillName: path.basename(path.dirname(skillPath)), + skillPath, + structureIssues: [], + triggerIssues: [] + }; + + if (!fs.existsSync(skillPath)) { + results.structureIssues.push({ + issue: 'File not found', + file: skillPath, + certainty: 'HIGH', + patternId: 'file_not_found' + }); + return results; + } + + let content = ''; + try { + content = fs.readFileSync(skillPath, 'utf8'); + } catch (err) { + results.structureIssues.push({ + issue: `Failed to read file: ${err.message}`, + file: skillPath, + certainty: 'HIGH', + patternId: 'read_error' + }); + return results; + } + + const missingFm = skillPatterns.missing_frontmatter.check(content); + if (missingFm) { + results.structureIssues.push({ + ...missingFm, + file: skillPath, + certainty: skillPatterns.missing_frontmatter.certainty, + patternId: skillPatterns.missing_frontmatter.id + }); + } + + const { frontmatter } = parseMarkdownFrontmatter(content); + const missingName = skillPatterns.missing_name.check(frontmatter); + if (missingName) { + results.structureIssues.push({ + ...missingName, + file: skillPath, + certainty: skillPatterns.missing_name.certainty, + patternId: skillPatterns.missing_name.id + }); + } + + const missingDescription = skillPatterns.missing_description.check(frontmatter); + if (missingDescription) { + results.structureIssues.push({ + ...missingDescription, + file: skillPath, + certainty: skillPatterns.missing_description.certainty, + patternId: skillPatterns.missing_description.id + }); + } + + const missingTrigger = skillPatterns.missing_trigger_phrase.check(frontmatter); + if (missingTrigger) { + results.triggerIssues.push({ + ...missingTrigger, + file: skillPath, + certainty: skillPatterns.missing_trigger_phrase.certainty, + patternId: skillPatterns.missing_trigger_phrase.id + }); + } + + return results; +} + +function analyzeAllSkills(skillsDir) { + const results = []; + if (!fs.existsSync(skillsDir)) return results; + + const skillFiles = []; + const skipDirs = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'target']); + + function walk(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (err) { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!skipDirs.has(entry.name)) { + walk(fullPath); + } + continue; + } + + if (entry.isFile() && entry.name === 'SKILL.md') { + skillFiles.push(fullPath); + } + } + } + + walk(skillsDir); + + for (const skillPath of skillFiles) { + results.push(analyzeSkill(skillPath)); + } + + return results; +} + +function analyze(options = {}) { + const { + skill, + skillsDir = 'plugins/enhance/skills' + } = options; + + if (skill) { + const skillPath = skill.endsWith('SKILL.md') + ? skill + : path.join(skillsDir, skill, 'SKILL.md'); + return analyzeSkill(skillPath); + } + + return analyzeAllSkills(skillsDir); +} + +module.exports = { + analyzeSkill, + analyzeAllSkills, + analyze +}; diff --git a/lib/enhance/skill-patterns.js b/lib/enhance/skill-patterns.js new file mode 100644 index 00000000..50872c58 --- /dev/null +++ b/lib/enhance/skill-patterns.js @@ -0,0 +1,51 @@ +/** + * Skill patterns for /enhance. + */ + +const skillPatterns = { + missing_frontmatter: { + id: 'missing_frontmatter', + certainty: 'HIGH', + check(content) { + if (!content || !content.trim().startsWith('---')) { + return { issue: 'Missing YAML frontmatter in SKILL.md' }; + } + return null; + } + }, + missing_name: { + id: 'missing_name', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.name) { + return { issue: 'Missing name in SKILL.md frontmatter' }; + } + return null; + } + }, + missing_description: { + id: 'missing_description', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.description) { + return { issue: 'Missing description in SKILL.md frontmatter' }; + } + return null; + } + }, + missing_trigger_phrase: { + id: 'missing_trigger_phrase', + certainty: 'MEDIUM', + check(frontmatter) { + if (!frontmatter || !frontmatter.description) return null; + if (!/use when user asks/i.test(frontmatter.description)) { + return { issue: 'Description missing "Use when user asks" trigger phrase' }; + } + return null; + } + } +}; + +module.exports = { + skillPatterns +}; diff --git a/lib/index.js b/lib/index.js index 646eb350..07706b6c 100644 --- a/lib/index.js +++ b/lib/index.js @@ -26,6 +26,7 @@ const policyQuestions = require('./sources/policy-questions'); const crossPlatform = require('./cross-platform'); const enhance = require('./enhance'); const repoMap = require('./repo-map'); +const perf = require('./perf'); /** * Platform detection and verification utilities @@ -228,6 +229,7 @@ module.exports = { xplat, enhance, repoMap, + perf, // Direct module access for backward compatibility detectPlatform, diff --git a/lib/perf/analyzer/index.js b/lib/perf/analyzer/index.js new file mode 100644 index 00000000..87fd5c4f --- /dev/null +++ b/lib/perf/analyzer/index.js @@ -0,0 +1,22 @@ +/** + * Perf analysis helpers. + * + * @module lib/perf/analyzer + */ + +/** + * Build a compact summary of perf findings. + * @param {object} input + * @returns {object} + */ +function summarize(input = {}) { + return { + summary: input.summary || '', + recommendations: input.recommendations || [], + risks: input.risks || [] + }; +} + +module.exports = { + summarize +}; diff --git a/lib/perf/argument-parser.js b/lib/perf/argument-parser.js new file mode 100644 index 00000000..46b04d35 --- /dev/null +++ b/lib/perf/argument-parser.js @@ -0,0 +1,65 @@ +/** + * Argument parsing helper for /perf. + * + * @module lib/perf/argument-parser + */ + +function parseArguments(raw) { + if (!raw || typeof raw !== 'string') return []; + + const args = []; + let current = ''; + let quote = null; + let escaped = false; + + for (let i = 0; i < raw.length; i++) { + const ch = raw[i]; + + if (escaped) { + current += ch; + escaped = false; + continue; + } + + if (ch === '\\') { + if (quote) { + escaped = true; + continue; + } + } + + if (quote) { + if (ch === quote) { + quote = null; + } else { + current += ch; + } + continue; + } + + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + + if (/\s/.test(ch)) { + if (current) { + args.push(current); + current = ''; + } + continue; + } + + current += ch; + } + + if (current) { + args.push(current); + } + + return args; +} + +module.exports = { + parseArguments +}; diff --git a/lib/perf/baseline-comparator.js b/lib/perf/baseline-comparator.js new file mode 100644 index 00000000..7e71220a --- /dev/null +++ b/lib/perf/baseline-comparator.js @@ -0,0 +1,50 @@ +/** + * Baseline comparison helpers + * + * @module lib/perf/baseline-comparator + */ + +/** + * Compute delta between baseline and current metrics. + * Supports flat numeric values under baseline.metrics/current.metrics. + * + * @param {object} baseline + * @param {object} current + * @returns {object} + */ +function compareBaselines(baseline, current) { + const baselineMetrics = baseline?.metrics || {}; + const currentMetrics = current?.metrics || {}; + const keys = new Set([ + ...Object.keys(baselineMetrics), + ...Object.keys(currentMetrics) + ]); + + const deltas = {}; + for (const key of keys) { + const baseValue = baselineMetrics[key]; + const currentValue = currentMetrics[key]; + + if (typeof baseValue === 'number' && typeof currentValue === 'number') { + const delta = currentValue - baseValue; + const percent = baseValue === 0 ? null : delta / baseValue; + deltas[key] = { baseline: baseValue, current: currentValue, delta, percent }; + } else { + deltas[key] = { + baseline: baseValue ?? null, + current: currentValue ?? null, + delta: null, + percent: null + }; + } + } + + return { + comparedAt: new Date().toISOString(), + metrics: deltas + }; +} + +module.exports = { + compareBaselines +}; diff --git a/lib/perf/baseline-store.js b/lib/perf/baseline-store.js new file mode 100644 index 00000000..f8c8a21f --- /dev/null +++ b/lib/perf/baseline-store.js @@ -0,0 +1,127 @@ +/** + * Baseline storage utilities for /perf + * + * Stores baselines under: + * - {state-dir}/perf/baselines/{version}.json + * + * @module lib/perf/baseline-store + */ + +const fs = require('fs'); +const path = require('path'); +const { getStateDir } = require('../platform/state-dir'); +const { validateBaseline, assertValid } = require('./schemas'); + +const BASELINE_DIR = 'baselines'; + +function assertSafeBaselineVersion(version) { + if (!version || typeof version !== 'string') { + throw new Error('Baseline version is required'); + } + if (version.includes('..') || version.includes('/') || version.includes('\\') || version.includes('\0')) { + throw new Error('Baseline version contains invalid characters'); + } + if (!/^[a-zA-Z0-9._+-]+$/.test(version)) { + throw new Error('Baseline version contains invalid characters'); + } + return version; +} + +/** + * Get baseline directory path + * @param {string} basePath + * @returns {string} + */ +function getBaselineDir(basePath = process.cwd()) { + return path.join(basePath, getStateDir(basePath), 'perf', BASELINE_DIR); +} + +/** + * Ensure baseline directory exists + * @param {string} basePath + * @returns {string} + */ +function ensureBaselineDir(basePath = process.cwd()) { + const dir = getBaselineDir(basePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + return dir; +} + +/** + * Build baseline file path + * @param {string} version + * @param {string} basePath + * @returns {string} + */ +function getBaselinePath(version, basePath = process.cwd()) { + const safeVersion = assertSafeBaselineVersion(version); + return path.join(ensureBaselineDir(basePath), `${safeVersion}.json`); +} + +/** + * List baseline versions + * @param {string} basePath + * @returns {string[]} + */ +function listBaselines(basePath = process.cwd()) { + const dir = ensureBaselineDir(basePath); + return fs.readdirSync(dir) + .filter(file => file.endsWith('.json')) + .map(file => path.basename(file, '.json')) + .sort(); +} + +/** + * Read baseline file + * @param {string} version + * @param {string} basePath + * @returns {object|null} + */ +function readBaseline(version, basePath = process.cwd()) { + const baselinePath = getBaselinePath(version, basePath); + if (!fs.existsSync(baselinePath)) { + return null; + } + try { + const parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf8')); + const validation = validateBaseline(parsed); + if (!validation.ok) { + console.error(`[CRITICAL] Invalid baseline file at ${baselinePath}: ${validation.errors.join(', ')}`); + return null; + } + return parsed; + } catch (error) { + console.error(`[CRITICAL] Corrupted baseline file at ${baselinePath}: ${error.message}`); + return null; + } +} + +/** + * Write baseline file (overwrites existing) + * @param {string} version + * @param {object} baseline + * @param {string} basePath + * @returns {boolean} + */ +function writeBaseline(version, baseline, basePath = process.cwd()) { + const baselinePath = getBaselinePath(version, basePath); + const payload = { + version, + recordedAt: new Date().toISOString(), + ...baseline + }; + assertValid(validateBaseline(payload), 'Invalid baseline payload'); + fs.writeFileSync(baselinePath, JSON.stringify(payload, null, 2), 'utf8'); + return true; +} + +module.exports = { + getBaselineDir, + ensureBaselineDir, + getBaselinePath, + listBaselines, + readBaseline, + writeBaseline +}; diff --git a/lib/perf/benchmark-runner.js b/lib/perf/benchmark-runner.js new file mode 100644 index 00000000..c245815c --- /dev/null +++ b/lib/perf/benchmark-runner.js @@ -0,0 +1,107 @@ +/** + * Sequential benchmark runner utilities. + * + * @module lib/perf/benchmark-runner + */ + +const { execSync } = require('child_process'); +const { validateBaseline } = require('./schemas'); + +const DEFAULT_MIN_DURATION = 60; +const BINARY_SEARCH_MIN_DURATION = 30; + +/** + * Normalize benchmark options and enforce minimum durations. + * @param {object} options + * @returns {object} + */ +function normalizeBenchmarkOptions(options = {}) { + const mode = options.mode || 'full'; + const minDuration = mode === 'binary-search' + ? BINARY_SEARCH_MIN_DURATION + : DEFAULT_MIN_DURATION; + + const duration = Math.max(options.duration || minDuration, minDuration); + return { + ...options, + mode, + duration, + warmup: options.warmup || 10 + }; +} + +/** + * Run a benchmark command synchronously (sequential only). + * @param {string} command + * @param {object} options + * @returns {{ success: boolean, output: string }} + */ +function runBenchmark(command, options = {}) { + if (!command || typeof command !== 'string') { + throw new Error('Benchmark command must be a non-empty string'); + } + + const normalized = normalizeBenchmarkOptions(options); + const env = { ...process.env, ...normalized.env }; + + const output = execSync(command, { + stdio: 'pipe', + encoding: 'utf8', + env + }); + + return { + success: true, + output, + duration: normalized.duration, + warmup: normalized.warmup, + mode: normalized.mode + }; +} + +/** + * Parse metrics from benchmark output using PERF_METRICS markers. + * @param {string} output + * @returns {{ ok: boolean, metrics?: object, error?: string }} + */ +function parseMetrics(output) { + if (typeof output !== 'string') { + return { ok: false, error: 'Output must be a string' }; + } + + const startMarker = 'PERF_METRICS_START'; + const endMarker = 'PERF_METRICS_END'; + const startIndex = output.indexOf(startMarker); + const endIndex = output.indexOf(endMarker); + + if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) { + return { ok: false, error: 'Metrics markers not found' }; + } + + const jsonStart = startIndex + startMarker.length; + const raw = output.slice(jsonStart, endIndex).trim(); + + try { + const parsed = JSON.parse(raw); + const validation = validateBaseline({ + version: 'temp', + recordedAt: new Date().toISOString(), + command: 'temp', + metrics: parsed + }); + if (!validation.ok) { + return { ok: false, error: `Invalid metrics: ${validation.errors.join(', ')}` }; + } + return { ok: true, metrics: parsed }; + } catch (error) { + return { ok: false, error: `Failed to parse metrics JSON: ${error.message}` }; + } +} + +module.exports = { + DEFAULT_MIN_DURATION, + BINARY_SEARCH_MIN_DURATION, + normalizeBenchmarkOptions, + runBenchmark, + parseMetrics +}; diff --git a/lib/perf/breaking-point-finder.js b/lib/perf/breaking-point-finder.js new file mode 100644 index 00000000..d7239cce --- /dev/null +++ b/lib/perf/breaking-point-finder.js @@ -0,0 +1,52 @@ +/** + * Binary search helper for breaking point discovery. + * + * @module lib/perf/breaking-point-finder + */ + +/** + * Find breaking point using binary search. + * The runner should return { ok: boolean, data?: any }. + * + * @param {object} options + * @param {number} options.min + * @param {number} options.max + * @param {(value:number)=>Promise<{ok:boolean,data?:any}>} options.runner + * @returns {Promise<{breakingPoint:number|null, attempts:number, history:Array}>} + */ +async function findBreakingPoint({ min, max, runner }) { + if (typeof min !== 'number' || typeof max !== 'number') { + throw new Error('min and max must be numbers'); + } + if (typeof runner !== 'function') { + throw new Error('runner must be a function'); + } + + let low = min; + let high = max; + let breakingPoint = null; + const history = []; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const result = await runner(mid); + history.push({ value: mid, ok: result.ok }); + + if (result.ok) { + low = mid + 1; + } else { + breakingPoint = mid; + high = mid - 1; + } + } + + return { + breakingPoint, + attempts: history.length, + history + }; +} + +module.exports = { + findBreakingPoint +}; diff --git a/lib/perf/breaking-point-runner.js b/lib/perf/breaking-point-runner.js new file mode 100644 index 00000000..0f15d5af --- /dev/null +++ b/lib/perf/breaking-point-runner.js @@ -0,0 +1,60 @@ +/** + * Breaking point runner wrapper for /perf. + * + * @module lib/perf/breaking-point-runner + */ + +const { runBenchmark, parseMetrics, BINARY_SEARCH_MIN_DURATION } = require('./benchmark-runner'); +const { findBreakingPoint } = require('./breaking-point-finder'); + +/** + * Run a binary search to find the breaking point for a numeric parameter. + * The benchmark command should accept the value via an env var. + * + * @param {object} options + * @param {string} options.command + * @param {string} options.paramEnv + * @param {number} options.min + * @param {number} options.max + * @returns {Promise<{breakingPoint:number|null, attempts:number, history:Array}>} + */ +async function runBreakingPointSearch(options) { + const { command, paramEnv, min, max } = options || {}; + + if (!command || typeof command !== 'string') { + throw new Error('command must be a non-empty string'); + } + if (!paramEnv || typeof paramEnv !== 'string') { + throw new Error('paramEnv must be a non-empty string'); + } + if (typeof min !== 'number' || typeof max !== 'number') { + throw new Error('min and max must be numbers'); + } + + const runner = async (value) => { + try { + const result = runBenchmark(command, { + mode: 'binary-search', + duration: BINARY_SEARCH_MIN_DURATION, + env: { + [paramEnv]: String(value) + } + }); + + const parsed = parseMetrics(result.output); + if (!parsed.ok) { + return { ok: false, data: { error: parsed.error } }; + } + + return { ok: true, data: { metrics: parsed.metrics } }; + } catch (error) { + return { ok: false, data: { error: error.message } }; + } + }; + + return findBreakingPoint({ min, max, runner }); +} + +module.exports = { + runBreakingPointSearch +}; diff --git a/lib/perf/checkpoint.js b/lib/perf/checkpoint.js new file mode 100644 index 00000000..8926f855 --- /dev/null +++ b/lib/perf/checkpoint.js @@ -0,0 +1,99 @@ +/** + * Git checkpoint helper for /perf phases. + * + * @module lib/perf/checkpoint + */ + +const { execSync, execFileSync } = require('child_process'); + +/** + * Check if git repo is clean. + * @returns {boolean} + */ +function isWorkingTreeClean() { + const output = execSync('git status --porcelain', { encoding: 'utf8' }).trim(); + return output.length === 0; +} + +/** + * Build checkpoint commit message. + * @param {object} input + * @param {string} input.phase + * @param {string} input.id + * @param {string} [input.baselineVersion] + * @param {string} [input.deltaSummary] + * @returns {string} + */ +function buildCheckpointMessage(input) { + if (!input || typeof input !== 'object') { + throw new Error('Checkpoint input must be an object'); + } + const { phase, id, baselineVersion, deltaSummary } = input; + + if (!phase || typeof phase !== 'string') { + throw new Error('phase is required'); + } + if (!id || typeof id !== 'string') { + throw new Error('id is required'); + } + + const baseline = baselineVersion || 'n/a'; + const delta = deltaSummary || 'n/a'; + return `perf: phase ${phase} [${id}] baseline=${baseline} delta=${delta}`; +} + +/** + * Get the most recent git commit message. + * @returns {string|null} + */ +function getLastCommitMessage() { + try { + return execSync('git log -1 --pretty=%B', { encoding: 'utf8' }).trim(); + } catch { + return null; + } +} + +/** + * Check if the next checkpoint would duplicate the last commit. + * @param {string} message + * @returns {boolean} + */ +function isDuplicateCheckpoint(message) { + const last = getLastCommitMessage(); + if (!last) return false; + return last.trim() === String(message || '').trim(); +} + +/** + * Commit a checkpoint for a perf phase. + * @param {object} input + * @returns {{ ok: boolean, message?: string, reason?: string }} + */ +function commitCheckpoint(input) { + try { + execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' }); + } catch { + return { ok: false, reason: 'not a git repo' }; + } + + if (isWorkingTreeClean()) { + return { ok: false, reason: 'nothing to commit' }; + } + + const message = buildCheckpointMessage(input); + if (isDuplicateCheckpoint(message)) { + return { ok: false, reason: 'duplicate checkpoint' }; + } + execFileSync('git', ['add', '-A'], { stdio: 'ignore' }); + execFileSync('git', ['commit', '-m', message], { stdio: 'ignore' }); + return { ok: true, message }; +} + +module.exports = { + isWorkingTreeClean, + buildCheckpointMessage, + getLastCommitMessage, + isDuplicateCheckpoint, + commitCheckpoint +}; diff --git a/lib/perf/code-paths.js b/lib/perf/code-paths.js new file mode 100644 index 00000000..ece2c8bf --- /dev/null +++ b/lib/perf/code-paths.js @@ -0,0 +1,86 @@ +/** + * Code-path discovery helpers for /perf. + * + * @module lib/perf/code-paths + */ + +const DEFAULT_STOPWORDS = new Set([ + 'the', 'and', 'for', 'with', 'from', 'that', 'this', 'these', 'those', + 'into', 'over', 'under', 'than', 'then', 'when', 'where', 'what', 'which', + 'your', 'you', 'our', 'their', 'there', 'have', 'has', 'had', 'will', + 'would', 'should', 'could', 'about', 'across', 'after', 'before', 'while', + 'perf', 'performance', 'investigation', 'baseline', 'benchmark', 'scenario' +]); + +function normalizeKeywords(text) { + if (!text || typeof text !== 'string') return []; + const tokens = text + .toLowerCase() + .split(/[^a-z0-9]+/g) + .filter(Boolean) + .filter(token => token.length > 2) + .filter(token => !DEFAULT_STOPWORDS.has(token)); + + return Array.from(new Set(tokens)); +} + +function scoreEntry(entry, keywords) { + let score = 0; + if (!entry || keywords.length === 0) return score; + + const haystack = [ + entry.file || '', + ...(entry.symbols || []) + ].join(' ').toLowerCase(); + + for (const keyword of keywords) { + if (haystack.includes(keyword)) score += 1; + } + + return score; +} + +function extractSymbols(fileData) { + if (!fileData || !fileData.symbols) return []; + const symbols = []; + for (const group of Object.values(fileData.symbols)) { + if (!Array.isArray(group)) continue; + for (const symbol of group) { + if (symbol && symbol.name) symbols.push(symbol.name); + } + } + return symbols; +} + +function collectCodePaths(repoMap, scenario, limit = 12) { + if (!repoMap || !repoMap.files) { + return { keywords: normalizeKeywords(scenario), paths: [] }; + } + + const keywords = normalizeKeywords(scenario); + const candidates = []; + + for (const [file, data] of Object.entries(repoMap.files)) { + const symbols = extractSymbols(data); + const entry = { file, symbols }; + const score = scoreEntry(entry, keywords); + if (score <= 0) continue; + candidates.push({ ...entry, score }); + } + + candidates.sort((a, b) => b.score - a.score || a.file.localeCompare(b.file)); + + return { + keywords, + paths: candidates.slice(0, limit).map(item => ({ + file: item.file, + score: item.score, + symbols: item.symbols.slice(0, 8) + })) + }; +} + +module.exports = { + normalizeKeywords, + collectCodePaths +}; diff --git a/lib/perf/consolidation.js b/lib/perf/consolidation.js new file mode 100644 index 00000000..f8c292da --- /dev/null +++ b/lib/perf/consolidation.js @@ -0,0 +1,37 @@ +/** + * Baseline consolidation helper. + * + * @module lib/perf/consolidation + */ + +const baselineStore = require('./baseline-store'); + +/** + * Consolidate a baseline for a version (overwrite existing). + * @param {object} input + * @param {string} input.version + * @param {object} input.baseline + * @param {string} [basePath] + * @returns {{ version: string, path: string }} + */ +function consolidateBaseline(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('consolidateBaseline requires an input object'); + } + const { version, baseline } = input; + + if (!version || typeof version !== 'string') { + throw new Error('version is required'); + } + if (!baseline || typeof baseline !== 'object') { + throw new Error('baseline is required'); + } + + baselineStore.writeBaseline(version, baseline, basePath); + const path = baselineStore.getBaselinePath(version, basePath); + return { version, path }; +} + +module.exports = { + consolidateBaseline +}; diff --git a/lib/perf/constraint-runner.js b/lib/perf/constraint-runner.js new file mode 100644 index 00000000..a5c5f6a5 --- /dev/null +++ b/lib/perf/constraint-runner.js @@ -0,0 +1,69 @@ +/** + * Constraint testing runner for /perf. + * + * @module lib/perf/constraint-runner + */ + +const { runBenchmark, parseMetrics, DEFAULT_MIN_DURATION } = require('./benchmark-runner'); +const { compareBaselines } = require('./baseline-comparator'); + +/** + * Run baseline and constrained benchmarks sequentially. + * Constraints are provided via env vars to keep it cross-platform. + * + * @param {object} options + * @param {string} options.command + * @param {object} options.constraints + * @param {object} [options.env] + * @returns {{ constraints: object, baseline: object, constrained: object, delta: object }} + */ +function runConstraintTest(options) { + const { command, constraints, env } = options || {}; + + if (!command || typeof command !== 'string') { + throw new Error('command must be a non-empty string'); + } + if (!constraints || typeof constraints !== 'object' || Array.isArray(constraints)) { + throw new Error('constraints must be an object'); + } + + const baselineResult = runBenchmark(command, { + duration: DEFAULT_MIN_DURATION, + env: { + ...env + } + }); + const baselineMetrics = parseMetrics(baselineResult.output); + if (!baselineMetrics.ok) { + throw new Error(`Baseline metrics parse failed: ${baselineMetrics.error}`); + } + + const constrainedResult = runBenchmark(command, { + duration: DEFAULT_MIN_DURATION, + env: { + ...env, + PERF_CPU_LIMIT: constraints.cpu, + PERF_MEMORY_LIMIT: constraints.memory + } + }); + const constrainedMetrics = parseMetrics(constrainedResult.output); + if (!constrainedMetrics.ok) { + throw new Error(`Constrained metrics parse failed: ${constrainedMetrics.error}`); + } + + const delta = compareBaselines( + { metrics: baselineMetrics.metrics }, + { metrics: constrainedMetrics.metrics } + ); + + return { + constraints, + baseline: { metrics: baselineMetrics.metrics }, + constrained: { metrics: constrainedMetrics.metrics }, + delta + }; +} + +module.exports = { + runConstraintTest +}; diff --git a/lib/perf/experiment-runner.js b/lib/perf/experiment-runner.js new file mode 100644 index 00000000..fbee670d --- /dev/null +++ b/lib/perf/experiment-runner.js @@ -0,0 +1,32 @@ +/** + * Experiment runner utilities. + * + * @module lib/perf/experiment-runner + */ + +/** + * Run experiments sequentially (never parallel). + * @param {Array} experiments + * @param {(experiment:object)=>Promise} runner + * @returns {Promise<{results:Array}>} + */ +async function runExperiments(experiments, runner) { + if (!Array.isArray(experiments)) { + throw new Error('experiments must be an array'); + } + if (typeof runner !== 'function') { + throw new Error('runner must be a function'); + } + + const results = []; + for (const experiment of experiments) { + const result = await runner(experiment); + results.push(result); + } + + return { results }; +} + +module.exports = { + runExperiments +}; diff --git a/lib/perf/index.js b/lib/perf/index.js new file mode 100644 index 00000000..2a8a689a --- /dev/null +++ b/lib/perf/index.js @@ -0,0 +1,41 @@ +/** + * Performance investigation utilities + * + * @module lib/perf + */ + +const investigationState = require('./investigation-state'); +const baselineStore = require('./baseline-store'); +const baselineComparator = require('./baseline-comparator'); +const benchmarkRunner = require('./benchmark-runner'); +const breakingPointFinder = require('./breaking-point-finder'); +const breakingPointRunner = require('./breaking-point-runner'); +const experimentRunner = require('./experiment-runner'); +const constraintRunner = require('./constraint-runner'); +const checkpoint = require('./checkpoint'); +const profilingRunner = require('./profiling-runner'); +const optimizationRunner = require('./optimization-runner'); +const consolidation = require('./consolidation'); +const profilers = require('./profilers'); +const analyzer = require('./analyzer'); +const argumentParser = require('./argument-parser'); +const codePaths = require('./code-paths'); + +module.exports = { + investigationState, + baselineStore, + baselineComparator, + benchmarkRunner, + breakingPointFinder, + breakingPointRunner, + experimentRunner, + constraintRunner, + checkpoint, + profilingRunner, + optimizationRunner, + consolidation, + profilers, + analyzer, + argumentParser, + codePaths +}; diff --git a/lib/perf/investigation-state.js b/lib/perf/investigation-state.js new file mode 100644 index 00000000..3bb091df --- /dev/null +++ b/lib/perf/investigation-state.js @@ -0,0 +1,788 @@ +/** + * Performance investigation state management + * + * Stores investigation state and logs under the platform-aware state directory: + * - {state-dir}/perf/investigation.json + * - {state-dir}/perf/investigations/{id}.md + * + * @module lib/perf/investigation-state + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { getStateDir } = require('../platform/state-dir'); +const { validateInvestigationState, assertValid } = require('./schemas'); + +const SCHEMA_VERSION = 1; +const INVESTIGATION_FILE = 'investigation.json'; +const LOG_DIR = 'investigations'; +const BASELINE_DIR = 'baselines'; + +const PHASES = [ + 'setup', + 'baseline', + 'breaking-point', + 'constraints', + 'hypotheses', + 'code-paths', + 'profiling', + 'optimization', + 'decision', + 'consolidation' +]; + +/** + * Validate and resolve path to prevent path traversal attacks + * @param {string} basePath - Base directory path + * @returns {string} Validated absolute path + */ +function validatePath(basePath) { + if (typeof basePath !== 'string' || basePath.length === 0) { + throw new Error('Path must be a non-empty string'); + } + const resolved = path.resolve(basePath); + if (resolved.includes('\0')) { + throw new Error('Path contains invalid null byte'); + } + return resolved; +} + +/** + * Validate that target path is within base directory + * @param {string} targetPath - Target file path + * @param {string} basePath - Base directory + */ +function validatePathWithinBase(targetPath, basePath) { + const resolvedTarget = path.resolve(targetPath); + const resolvedBase = path.resolve(basePath); + if (!resolvedTarget.startsWith(resolvedBase + path.sep) && resolvedTarget !== resolvedBase) { + throw new Error('Path traversal detected'); + } +} + +function assertSafeInvestigationId(id) { + if (!id || typeof id !== 'string') { + throw new Error('Investigation id is required'); + } + if (id.includes('..') || id.includes('/') || id.includes('\\') || id.includes('\0')) { + throw new Error('Investigation id contains invalid characters'); + } + if (!/^[a-zA-Z0-9._-]+$/.test(id)) { + throw new Error('Investigation id contains invalid characters'); + } + return id; +} + +/** + * Generate a unique investigation ID + * @returns {string} + */ +function generateInvestigationId() { + const now = new Date(); + const date = now.toISOString().slice(0, 10).replace(/-/g, ''); + const time = now.toISOString().slice(11, 19).replace(/:/g, ''); + const random = crypto.randomBytes(4).toString('hex'); + return `perf-${date}-${time}-${random}`; +} + +/** + * Get perf state directory path + * @param {string} basePath + * @returns {string} + */ +function getPerfDir(basePath = process.cwd()) { + const validatedBase = validatePath(basePath); + const perfDir = path.join(validatedBase, getStateDir(basePath), 'perf'); + validatePathWithinBase(perfDir, validatedBase); + return perfDir; +} + +/** + * Ensure perf directories exist + * @param {string} basePath + * @returns {{ perfDir: string, logDir: string, baselineDir: string }} + */ +function ensurePerfDirs(basePath = process.cwd()) { + const perfDir = getPerfDir(basePath); + const logDir = path.join(perfDir, LOG_DIR); + const baselineDir = path.join(perfDir, BASELINE_DIR); + + if (!fs.existsSync(perfDir)) { + fs.mkdirSync(perfDir, { recursive: true }); + } + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + if (!fs.existsSync(baselineDir)) { + fs.mkdirSync(baselineDir, { recursive: true }); + } + + return { perfDir, logDir, baselineDir }; +} + +/** + * Get path to investigation.json + * @param {string} basePath + * @returns {string} + */ +function getInvestigationPath(basePath = process.cwd()) { + const perfDir = getPerfDir(basePath); + return path.join(perfDir, INVESTIGATION_FILE); +} + +/** + * Get path to investigation log + * @param {string} id + * @param {string} basePath + * @returns {string} + */ +function getInvestigationLogPath(id, basePath = process.cwd()) { + const safeId = assertSafeInvestigationId(id); + const { logDir } = ensurePerfDirs(basePath); + return path.join(logDir, `${safeId}.md`); +} + +/** + * Read investigation.json + * @param {string} basePath + * @returns {object|null} + */ +function readInvestigation(basePath = process.cwd()) { + const investigationPath = getInvestigationPath(basePath); + if (!fs.existsSync(investigationPath)) { + return null; + } + try { + const parsed = JSON.parse(fs.readFileSync(investigationPath, 'utf8')); + const validation = validateInvestigationState(parsed); + if (!validation.ok) { + console.error(`[CRITICAL] Invalid investigation state at ${investigationPath}: ${validation.errors.join(', ')}`); + return null; + } + return parsed; + } catch (error) { + console.error(`[CRITICAL] Corrupted investigation.json at ${investigationPath}: ${error.message}`); + return null; + } +} + +/** + * Write investigation.json + * @param {object} state + * @param {string} basePath + * @returns {boolean} + */ +function writeInvestigation(state, basePath = process.cwd()) { + ensurePerfDirs(basePath); + const investigationPath = getInvestigationPath(basePath); + const nextState = { ...state, updatedAt: new Date().toISOString() }; + assertValid(validateInvestigationState(nextState), 'Invalid investigation state'); + fs.writeFileSync(investigationPath, JSON.stringify(nextState, null, 2), 'utf8'); + return true; +} + +/** + * Update investigation.json with partial updates + * @param {object} updates + * @param {string} basePath + * @returns {object|null} + */ +function updateInvestigation(updates, basePath = process.cwd()) { + const current = readInvestigation(basePath) || {}; + const nextState = { ...current }; + + for (const [key, value] of Object.entries(updates)) { + if (value === null) { + nextState[key] = null; + } else if ( + value && typeof value === 'object' && !Array.isArray(value) && + nextState[key] && typeof nextState[key] === 'object' && !Array.isArray(nextState[key]) + ) { + nextState[key] = { ...nextState[key], ...value }; + } else { + nextState[key] = value; + } + } + + writeInvestigation(nextState, basePath); + return readInvestigation(basePath); +} + +/** + * Initialize a new investigation + * @param {object} options + * @param {string} basePath + * @returns {object} + */ +function initializeInvestigation(options = {}, basePath = process.cwd()) { + const id = options.id || generateInvestigationId(); + const phase = options.phase || PHASES[0]; + + if (!PHASES.includes(phase)) { + throw new Error(`Invalid perf phase: ${phase}`); + } + + const state = { + schemaVersion: SCHEMA_VERSION, + id, + status: 'in_progress', + phase, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + scenario: { + description: options.scenario || '', + metrics: options.metrics || [], + successCriteria: options.successCriteria || '', + scenarios: Array.isArray(options.scenarios) ? options.scenarios : [] + }, + baselines: [], + hypotheses: [], + codePaths: [], + experiments: [], + results: [], + breakingPoint: null, + breakingPointHistory: [], + constraintResults: [], + profilingResults: [], + decision: null + }; + + assertValid(validateInvestigationState(state), 'Invalid initial investigation state'); + writeInvestigation(state, basePath); + return state; +} + +/** + * Append a line to the investigation log + * @param {string} id + * @param {string} content + * @param {string} basePath + */ +function appendInvestigationLog(id, content, basePath = process.cwd()) { + if (!content) return; + const logPath = getInvestigationLogPath(id, basePath); + const entry = content.endsWith('\n') ? content : `${content}\n`; + fs.appendFileSync(logPath, entry, 'utf8'); +} + +/** + * Append a baseline section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.command + * @param {object} input.metrics + * @param {string} input.baselinePath + * @param {string} [input.date] + * @param {string} basePath + */ +function appendBaselineLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendBaselineLog requires an input object'); + } + + const { id, userQuote, command, metrics, baselinePath, date, scenarios } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendBaselineLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendBaselineLog requires a non-empty userQuote'); + } + if (!command || typeof command !== 'string') { + throw new Error('appendBaselineLog requires a non-empty command'); + } + if (!metrics || typeof metrics !== 'object' || Array.isArray(metrics)) { + throw new Error('appendBaselineLog requires a metrics object'); + } + if (!baselinePath || typeof baselinePath !== 'string') { + throw new Error('appendBaselineLog requires a baselinePath'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const metricsText = JSON.stringify(metrics); + const scenarioText = Array.isArray(scenarios) && scenarios.length > 0 + ? scenarios.map((scenario) => scenario.name).filter(Boolean).join(', ') + : ''; + + const entry = [ + `## Baseline - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + scenarioText ? `- Scenarios: ${scenarioText}` : null, + `- Baseline command: \`${command}\``, + `- Metrics: ${metricsText}`, + '', + '**Evidence**', + `- Baseline file: ${baselinePath}`, + '' + ].filter(Boolean).join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a profiling section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.tool + * @param {string} input.command + * @param {string[]} input.artifacts + * @param {string[]} input.hotspots + * @param {string} [input.date] + * @param {string} basePath + */ +function appendProfilingLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendProfilingLog requires an input object'); + } + + const { id, userQuote, tool, command, artifacts, hotspots, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendProfilingLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendProfilingLog requires a non-empty userQuote'); + } + if (!tool || typeof tool !== 'string') { + throw new Error('appendProfilingLog requires a tool'); + } + if (!command || typeof command !== 'string') { + throw new Error('appendProfilingLog requires a command'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const artifactList = Array.isArray(artifacts) ? artifacts : []; + const hotspotList = Array.isArray(hotspots) ? hotspots : []; + + const entry = [ + `## Profiling - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Tool: ${tool}`, + `- Command: \`${command}\``, + '', + '**Evidence**', + artifactList.length ? `- Artifacts: ${artifactList.join(', ')}` : '- Artifacts: n/a', + hotspotList.length ? `- Hotspots: ${hotspotList.join(', ')}` : '- Hotspots: n/a', + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a decision section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.verdict + * @param {string} input.rationale + * @param {string} [input.date] + * @param {string} basePath + */ +function appendDecisionLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendDecisionLog requires an input object'); + } + + const { id, userQuote, verdict, rationale, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendDecisionLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendDecisionLog requires a non-empty userQuote'); + } + if (!verdict || typeof verdict !== 'string') { + throw new Error('appendDecisionLog requires a verdict'); + } + if (!rationale || typeof rationale !== 'string') { + throw new Error('appendDecisionLog requires a rationale'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + + const entry = [ + `## Decision - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Verdict: ${verdict}`, + `- Rationale: ${rationale}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a setup section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.scenario + * @param {string} input.command + * @param {string} input.version + * @param {string} [input.date] + * @param {string} basePath + */ +function appendSetupLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendSetupLog requires an input object'); + } + + const { id, userQuote, scenario, command, version, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendSetupLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendSetupLog requires a non-empty userQuote'); + } + if (!scenario || typeof scenario !== 'string') { + throw new Error('appendSetupLog requires a scenario'); + } + if (!command || typeof command !== 'string') { + throw new Error('appendSetupLog requires a command'); + } + if (!version || typeof version !== 'string') { + throw new Error('appendSetupLog requires a version'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Setup - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Scenario: ${scenario}`, + `- Command: \`${command}\``, + `- Version: ${version}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a breaking point section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.paramEnv + * @param {number} input.min + * @param {number} input.max + * @param {number|null} input.breakingPoint + * @param {string} [input.date] + * @param {string} basePath + */ +function appendBreakingPointLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendBreakingPointLog requires an input object'); + } + const { id, userQuote, paramEnv, min, max, breakingPoint, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendBreakingPointLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendBreakingPointLog requires a non-empty userQuote'); + } + if (!paramEnv || typeof paramEnv !== 'string') { + throw new Error('appendBreakingPointLog requires a paramEnv'); + } + if (typeof min !== 'number' || typeof max !== 'number') { + throw new Error('appendBreakingPointLog requires numeric min/max'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Breaking Point - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Param env: ${paramEnv}`, + `- Range: ${min}..${max}`, + `- Breaking point: ${breakingPoint ?? 'n/a'}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a constraints section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {object} input.constraints + * @param {object} input.delta + * @param {string} [input.date] + * @param {string} basePath + */ +function appendConstraintLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendConstraintLog requires an input object'); + } + const { id, userQuote, constraints, delta, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendConstraintLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendConstraintLog requires a non-empty userQuote'); + } + if (!constraints || typeof constraints !== 'object') { + throw new Error('appendConstraintLog requires constraints'); + } + if (!delta || typeof delta !== 'object') { + throw new Error('appendConstraintLog requires delta'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Constraints - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- CPU: ${constraints.cpu || 'n/a'}`, + `- Memory: ${constraints.memory || 'n/a'}`, + '', + '**Evidence**', + `- Delta: ${JSON.stringify(delta.metrics || {})}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a hypotheses section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {Array} input.hypotheses + * @param {string} [input.date] + * @param {string} basePath + */ +function appendHypothesesLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendHypothesesLog requires an input object'); + } + const { id, userQuote, hypotheses, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendHypothesesLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendHypothesesLog requires a non-empty userQuote'); + } + if (!Array.isArray(hypotheses)) { + throw new Error('appendHypothesesLog requires hypotheses array'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const lines = hypotheses.map((item) => { + if (!item) return null; + const label = item.id ? `${item.id}: ` : ''; + const evidence = item.evidence ? ` (evidence: ${item.evidence})` : ''; + const confidence = item.confidence ? ` [${item.confidence}]` : ''; + return `- ${label}${item.hypothesis || 'n/a'}${confidence}${evidence}`; + }).filter(Boolean); + + const entry = [ + `## Hypotheses - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + lines.length > 0 ? lines.join('\n') : '- n/a', + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a code-paths section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string[]} input.keywords + * @param {Array} input.paths + * @param {string} [input.date] + * @param {string} basePath + */ +function appendCodePathsLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendCodePathsLog requires an input object'); + } + const { id, userQuote, keywords, paths, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendCodePathsLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendCodePathsLog requires a non-empty userQuote'); + } + if (!Array.isArray(paths)) { + throw new Error('appendCodePathsLog requires paths array'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const keywordText = Array.isArray(keywords) && keywords.length > 0 ? keywords.join(', ') : 'n/a'; + const pathLines = paths.map((pathEntry) => { + const file = pathEntry.file || 'n/a'; + const score = typeof pathEntry.score === 'number' ? ` (score: ${pathEntry.score})` : ''; + const symbols = Array.isArray(pathEntry.symbols) && pathEntry.symbols.length > 0 + ? ` [${pathEntry.symbols.join(', ')}]` + : ''; + return `- ${file}${score}${symbols}`; + }); + + const entry = [ + `## Code Paths - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Keywords: ${keywordText}`, + pathLines.length > 0 ? pathLines.join('\n') : '- n/a', + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append an optimization section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.change + * @param {object} input.delta + * @param {string} input.verdict + * @param {string} [input.date] + * @param {string} basePath + */ +function appendOptimizationLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendOptimizationLog requires an input object'); + } + const { id, userQuote, change, delta, verdict, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendOptimizationLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendOptimizationLog requires a non-empty userQuote'); + } + if (!change || typeof change !== 'string') { + throw new Error('appendOptimizationLog requires a change summary'); + } + if (!delta || typeof delta !== 'object') { + throw new Error('appendOptimizationLog requires delta'); + } + if (!verdict || typeof verdict !== 'string') { + throw new Error('appendOptimizationLog requires a verdict'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Optimization - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Change: ${change}`, + `- Verdict: ${verdict}`, + '', + '**Evidence**', + `- Delta: ${JSON.stringify(delta.metrics || {})}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a consolidation section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.version + * @param {string} input.path + * @param {string} [input.date] + * @param {string} basePath + */ +function appendConsolidationLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendConsolidationLog requires an input object'); + } + + const { id, userQuote, version, path, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendConsolidationLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendConsolidationLog requires a non-empty userQuote'); + } + if (!version || typeof version !== 'string') { + throw new Error('appendConsolidationLog requires a version'); + } + if (!path || typeof path !== 'string') { + throw new Error('appendConsolidationLog requires a path'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Consolidation - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Version: ${version}`, + `- Baseline file: ${path}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +module.exports = { + SCHEMA_VERSION, + PHASES, + generateInvestigationId, + getPerfDir, + ensurePerfDirs, + getInvestigationPath, + getInvestigationLogPath, + readInvestigation, + writeInvestigation, + updateInvestigation, + initializeInvestigation, + appendInvestigationLog, + appendBaselineLog, + appendProfilingLog, + appendDecisionLog, + appendSetupLog, + appendBreakingPointLog, + appendConstraintLog, + appendHypothesesLog, + appendCodePathsLog, + appendOptimizationLog, + appendConsolidationLog +}; diff --git a/lib/perf/optimization-runner.js b/lib/perf/optimization-runner.js new file mode 100644 index 00000000..4fb6ca0d --- /dev/null +++ b/lib/perf/optimization-runner.js @@ -0,0 +1,67 @@ +/** + * Optimization runner for /perf experiments. + * + * @module lib/perf/optimization-runner + */ + +const { runBenchmark, parseMetrics, DEFAULT_MIN_DURATION } = require('./benchmark-runner'); +const { compareBaselines } = require('./baseline-comparator'); +const { isWorkingTreeClean } = require('./checkpoint'); + +/** + * Run a single optimization experiment with two benchmark runs. + * NOTE: This helper does not modify code; it assumes the change was applied externally. + * + * @param {object} options + * @param {string} options.command + * @param {string} options.changeSummary + * @param {object} [options.env] + * @returns {{ baseline: object, experiment: object, delta: object, verdict: string, change: string }} + */ +function runOptimizationExperiment(options) { + const { command, changeSummary, env } = options || {}; + + if (!command || typeof command !== 'string') { + throw new Error('command must be a non-empty string'); + } + if (!changeSummary || typeof changeSummary !== 'string') { + throw new Error('changeSummary must be a non-empty string'); + } + + const shouldCheckClean = options?.requireClean !== false; + if (shouldCheckClean && !isWorkingTreeClean()) { + throw new Error('working tree is dirty before experiment'); + } + + const baselineRun = runBenchmark(command, { duration: DEFAULT_MIN_DURATION, env }); + const baselineMetrics = parseMetrics(baselineRun.output); + if (!baselineMetrics.ok) { + throw new Error(`Baseline parse failed: ${baselineMetrics.error}`); + } + + // NOTE: Caller is responsible for applying the experiment change here. + // Warm up the system (caches/JIT) before capturing experiment metrics. + runBenchmark(command, { duration: DEFAULT_MIN_DURATION, env }); + const experimentRun = runBenchmark(command, { duration: DEFAULT_MIN_DURATION, env }); + const experimentMetrics = parseMetrics(experimentRun.output); + if (!experimentMetrics.ok) { + throw new Error(`Experiment parse failed: ${experimentMetrics.error}`); + } + + const delta = compareBaselines( + { metrics: baselineMetrics.metrics }, + { metrics: experimentMetrics.metrics } + ); + + return { + change: changeSummary, + baseline: { metrics: baselineMetrics.metrics }, + experiment: { metrics: experimentMetrics.metrics }, + delta, + verdict: 'inconclusive' + }; +} + +module.exports = { + runOptimizationExperiment +}; diff --git a/lib/perf/profilers/go.js b/lib/perf/profilers/go.js new file mode 100644 index 00000000..9616ae6e --- /dev/null +++ b/lib/perf/profilers/go.js @@ -0,0 +1,22 @@ +/** + * Go pprof helper. + * + * @module lib/perf/profilers/go + */ + +module.exports = { + id: 'pprof', + tool: 'pprof', + buildCommand(options = {}) { + const command = options.command || 'go test'; + const output = options.output || 'cpu.pprof'; + return `${command} -cpuprofile=${output}`; + }, + parseOutput() { + return { + tool: 'pprof', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/lib/perf/profilers/index.js b/lib/perf/profilers/index.js new file mode 100644 index 00000000..20e66af9 --- /dev/null +++ b/lib/perf/profilers/index.js @@ -0,0 +1,46 @@ +/** + * Profilers registry for /perf. + * + * @module lib/perf/profilers + */ + +const fs = require('fs'); +const path = require('path'); +const cliEnhancers = require('../../patterns/cli-enhancers'); +const nodeProfiler = require('./node'); +const pythonProfiler = require('./python'); +const goProfiler = require('./go'); +const rustProfiler = require('./rust'); +const javaProfiler = require('./java'); + +function hasJavaIndicators(repoPath) { + const indicators = ['pom.xml', 'build.gradle', 'build.gradle.kts']; + return indicators.some((file) => fs.existsSync(path.join(repoPath, file))); +} + +function selectProfiler(repoPath = process.cwd()) { + const languages = cliEnhancers.detectProjectLanguages(repoPath); + + if (hasJavaIndicators(repoPath)) return javaProfiler; + if (languages.includes('typescript') || languages.includes('javascript')) return nodeProfiler; + if (languages.includes('go')) return goProfiler; + if (languages.includes('python')) return pythonProfiler; + if (languages.includes('rust')) return rustProfiler; + + return nodeProfiler; +} + +function listAvailable() { + return [ + nodeProfiler.id, + javaProfiler.id, + pythonProfiler.id, + goProfiler.id, + rustProfiler.id + ]; +} + +module.exports = { + listAvailable, + selectProfiler +}; diff --git a/lib/perf/profilers/java.js b/lib/perf/profilers/java.js new file mode 100644 index 00000000..bb464130 --- /dev/null +++ b/lib/perf/profilers/java.js @@ -0,0 +1,23 @@ +/** + * Java JFR profiler helper. + * + * @module lib/perf/profilers/java + */ + +module.exports = { + id: 'jfr', + tool: 'jfr', + buildCommand(options = {}) { + const command = options.command || 'java'; + const output = options.output || 'profile.jfr'; + const duration = options.duration || '60s'; + return `${command} -XX:StartFlightRecording=duration=${duration},filename=${output}`; + }, + parseOutput() { + return { + tool: 'jfr', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/lib/perf/profilers/node.js b/lib/perf/profilers/node.js new file mode 100644 index 00000000..95b7a857 --- /dev/null +++ b/lib/perf/profilers/node.js @@ -0,0 +1,22 @@ +/** + * Node.js profiler helper. + * + * @module lib/perf/profilers/node + */ + +module.exports = { + id: 'node', + tool: '--cpu-prof', + buildCommand(options = {}) { + const command = options.command || 'node'; + const output = options.output || 'node.cpuprofile'; + return `${command} --cpu-prof --cpu-prof-name=${output}`; + }, + parseOutput() { + return { + tool: 'node', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/lib/perf/profilers/python.js b/lib/perf/profilers/python.js new file mode 100644 index 00000000..98ede075 --- /dev/null +++ b/lib/perf/profilers/python.js @@ -0,0 +1,23 @@ +/** + * Python cProfile helper. + * + * @module lib/perf/profilers/python + */ + +module.exports = { + id: 'cprofile', + tool: 'cProfile', + buildCommand(options = {}) { + const command = options.command || 'python'; + const target = options.target || '-m'; + const output = options.output || 'profile.prof'; + return `${command} -m cProfile -o ${output} ${target}`; + }, + parseOutput() { + return { + tool: 'cprofile', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/lib/perf/profilers/rust.js b/lib/perf/profilers/rust.js new file mode 100644 index 00000000..416186d6 --- /dev/null +++ b/lib/perf/profilers/rust.js @@ -0,0 +1,23 @@ +/** + * Rust perf helper (Linux). + * + * @module lib/perf/profilers/rust + */ + +module.exports = { + id: 'perf', + tool: 'perf', + buildCommand(options = {}) { + const command = options.command || 'perf record'; + const output = options.output || 'perf.data'; + const target = options.target || './target/release/app'; + return `${command} -o ${output} ${target}`; + }, + parseOutput() { + return { + tool: 'perf', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/lib/perf/profiling-runner.js b/lib/perf/profiling-runner.js new file mode 100644 index 00000000..e1204f25 --- /dev/null +++ b/lib/perf/profiling-runner.js @@ -0,0 +1,48 @@ +/** + * Profiling execution helper. + * + * @module lib/perf/profiling-runner + */ + +const { execSync } = require('child_process'); +const profilers = require('./profilers'); + +/** + * Run a profiling command and return artifacts/hotspots metadata. + * @param {object} options + * @param {string} [options.repoPath] + * @param {object} [options.profileOptions] + * @returns {{ ok: boolean, result?: object, error?: string }} + */ +function runProfiling(options = {}) { + const repoPath = options.repoPath || process.cwd(); + const profiler = profilers.selectProfiler(repoPath); + + if (!profiler || typeof profiler.buildCommand !== 'function') { + return { ok: false, error: 'No profiler available' }; + } + + const command = profiler.buildCommand(options.profileOptions || {}); + try { + execSync(command, { stdio: 'pipe' }); + } catch (error) { + return { ok: false, error: error.message }; + } + + const parsed = typeof profiler.parseOutput === 'function' + ? profiler.parseOutput() + : { tool: profiler.id, hotspots: [], artifacts: [] }; + + const result = { + tool: profiler.id, + command, + hotspots: parsed.hotspots || [], + artifacts: parsed.artifacts || [] + }; + + return { ok: true, result }; +} + +module.exports = { + runProfiling +}; diff --git a/lib/perf/schemas.js b/lib/perf/schemas.js new file mode 100644 index 00000000..8b86761e --- /dev/null +++ b/lib/perf/schemas.js @@ -0,0 +1,140 @@ +/** + * Schema validation helpers for /perf. + * + * @module lib/perf/schemas + */ + +const REQUIRED_INVESTIGATION_FIELDS = ['schemaVersion', 'id', 'status', 'phase', 'scenario']; +const REQUIRED_BASELINE_FIELDS = ['version', 'recordedAt', 'metrics', 'command']; + +function isObject(value) { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function validateInvestigationState(state) { + const errors = []; + + if (!isObject(state)) { + return { ok: false, errors: ['state must be an object'] }; + } + + for (const field of REQUIRED_INVESTIGATION_FIELDS) { + if (!Object.prototype.hasOwnProperty.call(state, field)) { + errors.push(`missing ${field}`); + } + } + + if (typeof state.id !== 'string' || state.id.trim().length === 0) { + errors.push('id must be a non-empty string'); + } + + if (typeof state.phase !== 'string' || state.phase.trim().length === 0) { + errors.push('phase must be a non-empty string'); + } + + if (!isObject(state.scenario)) { + errors.push('scenario must be an object'); + } else { + if (typeof state.scenario.description !== 'string') { + errors.push('scenario.description must be a string'); + } + if (!Array.isArray(state.scenario.metrics)) { + errors.push('scenario.metrics must be an array'); + } + if (typeof state.scenario.successCriteria !== 'string') { + errors.push('scenario.successCriteria must be a string'); + } + if (state.scenario.scenarios != null) { + if (!Array.isArray(state.scenario.scenarios)) { + errors.push('scenario.scenarios must be an array when provided'); + } else { + state.scenario.scenarios.forEach((scenario, index) => { + if (!isObject(scenario)) { + errors.push(`scenario.scenarios[${index}] must be an object`); + return; + } + if (typeof scenario.name !== 'string' || scenario.name.trim().length === 0) { + errors.push(`scenario.scenarios[${index}].name must be a non-empty string`); + } + if (scenario.params != null && !isObject(scenario.params)) { + errors.push(`scenario.scenarios[${index}].params must be an object when provided`); + } + }); + } + } + } + + return { ok: errors.length === 0, errors }; +} + +function validateBaseline(baseline) { + const errors = []; + + if (!isObject(baseline)) { + return { ok: false, errors: ['baseline must be an object'] }; + } + + for (const field of REQUIRED_BASELINE_FIELDS) { + if (!Object.prototype.hasOwnProperty.call(baseline, field)) { + errors.push(`missing ${field}`); + } + } + + if (typeof baseline.version !== 'string' || baseline.version.trim().length === 0) { + errors.push('version must be a non-empty string'); + } + + if (typeof baseline.recordedAt !== 'string' || baseline.recordedAt.trim().length === 0) { + errors.push('recordedAt must be an ISO8601 string'); + } + + if (typeof baseline.command !== 'string' || baseline.command.trim().length === 0) { + errors.push('command must be a non-empty string'); + } + + if (!isObject(baseline.metrics)) { + errors.push('metrics must be an object'); + } else { + if (baseline.metrics.scenarios != null) { + if (!isObject(baseline.metrics.scenarios)) { + errors.push('metrics.scenarios must be an object when provided'); + } else { + for (const [scenarioName, scenarioMetrics] of Object.entries(baseline.metrics.scenarios)) { + if (!isObject(scenarioMetrics)) { + errors.push(`metrics.scenarios.${scenarioName} must be an object`); + continue; + } + for (const [key, value] of Object.entries(scenarioMetrics)) { + if (typeof value !== 'number' || Number.isNaN(value)) { + errors.push(`metric ${scenarioName}.${key} must be a number`); + } + } + } + } + } else { + for (const [key, value] of Object.entries(baseline.metrics)) { + if (typeof value !== 'number' || Number.isNaN(value)) { + errors.push(`metric ${key} must be a number`); + } + } + } + } + + if (baseline.env && !isObject(baseline.env)) { + errors.push('env must be an object when provided'); + } + + return { ok: errors.length === 0, errors }; +} + +function assertValid(result, message) { + if (!result.ok) { + throw new Error(`${message}: ${result.errors.join(', ')}`); + } +} + +module.exports = { + validateInvestigationState, + validateBaseline, + assertValid +}; diff --git a/mcp-server/index.js b/mcp-server/index.js index 2d3c6441..c6eb1925 100644 --- a/mcp-server/index.js +++ b/mcp-server/index.js @@ -239,7 +239,7 @@ const TOOLS = [ }, { name: 'enhance_analyze', - description: 'Analyze plugins, agents, docs, or prompts for enhancement opportunities', + description: 'Analyze plugins, agents, docs, prompts, hooks, or skills for enhancement opportunities', inputSchema: { type: 'object', properties: { @@ -249,7 +249,7 @@ const TOOLS = [ }, focus: { type: 'string', - enum: ['all', 'plugin', 'agent', 'docs', 'claudemd', 'prompt'], + enum: ['all', 'plugin', 'agent', 'docs', 'claudemd', 'claude-memory', 'prompt', 'hooks', 'skills'], description: 'Which analyzer to run (default: all)' }, mode: { @@ -823,10 +823,10 @@ const toolHandlers = { }, async enhance_analyze({ path: scanPath, focus, mode, compact }) { - try { - const targetPath = scanPath || process.cwd(); - const analyzerFocus = focus || 'all'; - const analyzeMode = mode || 'report'; + try { + const targetPath = scanPath || process.cwd(); + const analyzerFocus = (focus === 'claude-memory') ? 'claudemd' : (focus || 'all'); + const analyzeMode = mode || 'report'; // Validate path exists try { @@ -835,69 +835,125 @@ const toolHandlers = { return crossPlatform.errorResponse(`Path not found: ${targetPath}`); } - const allFindings = []; - const summary = { plugin: 0, agent: 0, docs: 0, claudemd: 0, prompt: 0 }; + const allFindings = []; + const summary = { plugin: 0, agent: 0, docs: 0, claudemd: 0, prompt: 0, hooks: 0, skills: 0 }; + + function normalizeFindings(result, analyzer) { + if (!result) return []; + + if (Array.isArray(result.findings)) { + return result.findings.map(finding => ({ + ...finding, + analyzer: finding.analyzer || analyzer + })); + } + + const findings = []; + const resultsArray = Array.isArray(result) ? result : [result]; + for (const entry of resultsArray) { + if (!entry || typeof entry !== 'object') continue; + for (const value of Object.values(entry)) { + if (!Array.isArray(value)) continue; + for (const issue of value) { + if (!issue || typeof issue.issue !== 'string') continue; + findings.push({ ...issue, analyzer }); + } + } + } + return findings; + } // Run analyzers based on focus if (analyzerFocus === 'all' || analyzerFocus === 'plugin') { try { - const result = enhance.analyzeAllPlugins(targetPath); - if (result && result.findings) { - allFindings.push(...result.findings.map(f => ({ ...f, analyzer: 'plugin' }))); - summary.plugin = result.findings.length; + const result = enhance.analyzeAllPlugins(targetPath); + const findings = normalizeFindings(result, 'plugin'); + if (findings.length > 0) { + allFindings.push(...findings); + summary.plugin = findings.length; + } + } catch (e) { + console.error('Plugin analyzer error:', e.message); } - } catch (e) { - console.error('Plugin analyzer error:', e.message); } - } if (analyzerFocus === 'all' || analyzerFocus === 'agent') { try { - const result = enhance.analyzeAllAgents(targetPath); - if (result && result.findings) { - allFindings.push(...result.findings.map(f => ({ ...f, analyzer: 'agent' }))); - summary.agent = result.findings.length; + const result = enhance.analyzeAllAgents(targetPath); + const findings = normalizeFindings(result, 'agent'); + if (findings.length > 0) { + allFindings.push(...findings); + summary.agent = findings.length; + } + } catch (e) { + console.error('Agent analyzer error:', e.message); } - } catch (e) { - console.error('Agent analyzer error:', e.message); } - } if (analyzerFocus === 'all' || analyzerFocus === 'docs') { try { - const result = enhance.analyzeAllDocs(targetPath); - if (result && result.findings) { - allFindings.push(...result.findings.map(f => ({ ...f, analyzer: 'docs' }))); - summary.docs = result.findings.length; + const result = enhance.analyzeAllDocs(targetPath); + const findings = normalizeFindings(result, 'docs'); + if (findings.length > 0) { + allFindings.push(...findings); + summary.docs = findings.length; + } + } catch (e) { + console.error('Docs analyzer error:', e.message); } - } catch (e) { - console.error('Docs analyzer error:', e.message); } - } if (analyzerFocus === 'all' || analyzerFocus === 'claudemd') { try { - const result = enhance.analyzeProjectMemory(targetPath); - if (result && result.findings) { - allFindings.push(...result.findings.map(f => ({ ...f, analyzer: 'claudemd' }))); - summary.claudemd = result.findings.length; + const result = enhance.analyzeProjectMemory(targetPath); + const findings = normalizeFindings(result, 'claudemd'); + if (findings.length > 0) { + allFindings.push(...findings); + summary.claudemd = findings.length; + } + } catch (e) { + console.error('Project memory analyzer error:', e.message); } - } catch (e) { - console.error('Project memory analyzer error:', e.message); } - } - if (analyzerFocus === 'all' || analyzerFocus === 'prompt') { - try { - const result = enhance.analyzeAllPrompts(targetPath); - if (result && result.findings) { - allFindings.push(...result.findings.map(f => ({ ...f, analyzer: 'prompt' }))); - summary.prompt = result.findings.length; + if (analyzerFocus === 'all' || analyzerFocus === 'prompt') { + try { + const result = enhance.analyzeAllPrompts(targetPath); + const findings = normalizeFindings(result, 'prompt'); + if (findings.length > 0) { + allFindings.push(...findings); + summary.prompt = findings.length; + } + } catch (e) { + console.error('Prompt analyzer error:', e.message); + } + } + + if (analyzerFocus === 'all' || analyzerFocus === 'hooks') { + try { + const result = enhance.analyzeAllHooks(targetPath); + const findings = normalizeFindings(result, 'hooks'); + if (findings.length > 0) { + allFindings.push(...findings); + summary.hooks = findings.length; + } + } catch (e) { + console.error('Hook analyzer error:', e.message); + } + } + + if (analyzerFocus === 'all' || analyzerFocus === 'skills') { + try { + const result = enhance.analyzeAllSkills(targetPath); + const findings = normalizeFindings(result, 'skills'); + if (findings.length > 0) { + allFindings.push(...findings); + summary.skills = findings.length; + } + } catch (e) { + console.error('Skills analyzer error:', e.message); } - } catch (e) { - console.error('Prompt analyzer error:', e.message); } - } // Deduplicate if running all analyzers let findings = allFindings; @@ -921,7 +977,7 @@ const toolHandlers = { else if (fix.analyzer === 'agent') enhance.agentApplyFixes([fix]); else if (fix.analyzer === 'docs') enhance.docsApplyFixes([fix]); else if (fix.analyzer === 'claudemd') enhance.projectMemoryApplyFixes([fix]); - else if (fix.analyzer === 'prompt') enhance.promptApplyFixes([fix]); + else if (fix.analyzer === 'prompt') enhance.promptApplyFixes([fix]); fixResults.applied++; } catch (e) { console.error(`Fix failed for ${fix.file}:`, e.message); diff --git a/plugins/audit-project/lib/enhance/hook-analyzer.js b/plugins/audit-project/lib/enhance/hook-analyzer.js new file mode 100644 index 00000000..2530e111 --- /dev/null +++ b/plugins/audit-project/lib/enhance/hook-analyzer.js @@ -0,0 +1,135 @@ +/** + * Hook analyzer for /enhance. + */ + +const fs = require('fs'); +const path = require('path'); +const { hookPatterns } = require('./hook-patterns'); +const { parseMarkdownFrontmatter } = require('./agent-analyzer'); + +function analyzeHook(hookPath) { + const results = { + hookName: path.basename(hookPath, '.md'), + hookPath, + structureIssues: [] + }; + + if (!fs.existsSync(hookPath)) { + results.structureIssues.push({ + issue: 'File not found', + file: hookPath, + certainty: 'HIGH', + patternId: 'file_not_found' + }); + return results; + } + + let content = ''; + try { + content = fs.readFileSync(hookPath, 'utf8'); + } catch (err) { + results.structureIssues.push({ + issue: `Failed to read file: ${err.message}`, + file: hookPath, + certainty: 'HIGH', + patternId: 'read_error' + }); + return results; + } + + const missingFm = hookPatterns.missing_frontmatter.check(content); + if (missingFm) { + results.structureIssues.push({ + ...missingFm, + file: hookPath, + certainty: hookPatterns.missing_frontmatter.certainty, + patternId: hookPatterns.missing_frontmatter.id + }); + } + + const { frontmatter } = parseMarkdownFrontmatter(content); + const missingName = hookPatterns.missing_name.check(frontmatter); + if (missingName) { + results.structureIssues.push({ + ...missingName, + file: hookPath, + certainty: hookPatterns.missing_name.certainty, + patternId: hookPatterns.missing_name.id + }); + } + + const missingDescription = hookPatterns.missing_description.check(frontmatter); + if (missingDescription) { + results.structureIssues.push({ + ...missingDescription, + file: hookPath, + certainty: hookPatterns.missing_description.certainty, + patternId: hookPatterns.missing_description.id + }); + } + + return results; +} + +function analyzeAllHooks(hooksDir) { + const results = []; + if (!fs.existsSync(hooksDir)) return results; + + const hookFiles = []; + const skipDirs = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'target']); + + function walk(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (err) { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!skipDirs.has(entry.name)) { + walk(fullPath); + } + continue; + } + + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + const parts = fullPath.split(path.sep); + if (parts.includes('hooks')) { + hookFiles.push(fullPath); + } + } + } + + walk(hooksDir); + + for (const file of hookFiles) { + results.push(analyzeHook(file)); + } + + return results; +} + +function analyze(options = {}) { + const { + hook, + hooksDir = 'plugins/enhance/hooks' + } = options; + + if (hook) { + const hookPath = hook.endsWith('.md') + ? hook + : path.join(hooksDir, `${hook}.md`); + return analyzeHook(hookPath); + } + + return analyzeAllHooks(hooksDir); +} + +module.exports = { + analyzeHook, + analyzeAllHooks, + analyze +}; diff --git a/plugins/audit-project/lib/enhance/hook-patterns.js b/plugins/audit-project/lib/enhance/hook-patterns.js new file mode 100644 index 00000000..472c789b --- /dev/null +++ b/plugins/audit-project/lib/enhance/hook-patterns.js @@ -0,0 +1,40 @@ +/** + * Hook patterns for /enhance. + */ + +const hookPatterns = { + missing_frontmatter: { + id: 'missing_frontmatter', + certainty: 'HIGH', + check(content) { + if (!content || !content.trim().startsWith('---')) { + return { issue: 'Missing YAML frontmatter in hook file' }; + } + return null; + } + }, + missing_name: { + id: 'missing_name', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.name) { + return { issue: 'Missing name in hook frontmatter' }; + } + return null; + } + }, + missing_description: { + id: 'missing_description', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.description) { + return { issue: 'Missing description in hook frontmatter' }; + } + return null; + } + } +}; + +module.exports = { + hookPatterns +}; diff --git a/plugins/audit-project/lib/enhance/index.js b/plugins/audit-project/lib/enhance/index.js index 542e81fd..07539241 100644 --- a/plugins/audit-project/lib/enhance/index.js +++ b/plugins/audit-project/lib/enhance/index.js @@ -16,6 +16,8 @@ const projectmemoryAnalyzer = require('./projectmemory-analyzer'); const projectmemoryPatterns = require('./projectmemory-patterns'); const promptAnalyzer = require('./prompt-analyzer'); const promptPatterns = require('./prompt-patterns'); +const hookAnalyzer = require('./hook-analyzer'); +const skillAnalyzer = require('./skill-analyzer'); const reporter = require('./reporter'); const fixer = require('./fixer'); @@ -26,6 +28,8 @@ module.exports = { docsAnalyzer, projectmemoryAnalyzer, promptAnalyzer, + hookAnalyzer, + skillAnalyzer, // Pattern modules pluginPatterns, @@ -72,6 +76,16 @@ module.exports = { promptApplyFixes: promptAnalyzer.applyFixes, promptGenerateReport: promptAnalyzer.generateReport, + // Convenience exports - Hooks + analyzeHook: hookAnalyzer.analyzeHook, + analyzeAllHooks: hookAnalyzer.analyzeAllHooks, + hooksAnalyze: hookAnalyzer.analyze, + + // Convenience exports - Skills + analyzeSkill: skillAnalyzer.analyzeSkill, + analyzeAllSkills: skillAnalyzer.analyzeAllSkills, + skillsAnalyze: skillAnalyzer.analyze, + // Convenience exports - Orchestrator generateOrchestratorReport: reporter.generateOrchestratorReport, deduplicateOrchestratorFindings: reporter.deduplicateOrchestratorFindings diff --git a/plugins/audit-project/lib/enhance/reporter.js b/plugins/audit-project/lib/enhance/reporter.js index 7016a1f8..77b727c6 100644 --- a/plugins/audit-project/lib/enhance/reporter.js +++ b/plugins/audit-project/lib/enhance/reporter.js @@ -1091,7 +1091,7 @@ function generateOrchestratorReport(aggregatedResults, options = {}) { lines.push('| Enhancer | HIGH | MEDIUM | LOW | Auto-Fixable |'); lines.push('|----------|------|--------|-----|--------------|'); - const enhancerTypes = ['plugin', 'agent', 'claudemd', 'docs', 'prompt']; + const enhancerTypes = ['plugin', 'agent', 'claudemd', 'docs', 'prompt', 'hooks', 'skills']; let totalHigh = 0, totalMedium = 0, totalLow = 0, totalAutoFix = 0; for (const enhancer of enhancerTypes) { diff --git a/plugins/audit-project/lib/enhance/skill-analyzer.js b/plugins/audit-project/lib/enhance/skill-analyzer.js new file mode 100644 index 00000000..023ac494 --- /dev/null +++ b/plugins/audit-project/lib/enhance/skill-analyzer.js @@ -0,0 +1,144 @@ +/** + * Skill analyzer for /enhance. + */ + +const fs = require('fs'); +const path = require('path'); +const { skillPatterns } = require('./skill-patterns'); +const { parseMarkdownFrontmatter } = require('./agent-analyzer'); + +function analyzeSkill(skillPath) { + const results = { + skillName: path.basename(path.dirname(skillPath)), + skillPath, + structureIssues: [], + triggerIssues: [] + }; + + if (!fs.existsSync(skillPath)) { + results.structureIssues.push({ + issue: 'File not found', + file: skillPath, + certainty: 'HIGH', + patternId: 'file_not_found' + }); + return results; + } + + let content = ''; + try { + content = fs.readFileSync(skillPath, 'utf8'); + } catch (err) { + results.structureIssues.push({ + issue: `Failed to read file: ${err.message}`, + file: skillPath, + certainty: 'HIGH', + patternId: 'read_error' + }); + return results; + } + + const missingFm = skillPatterns.missing_frontmatter.check(content); + if (missingFm) { + results.structureIssues.push({ + ...missingFm, + file: skillPath, + certainty: skillPatterns.missing_frontmatter.certainty, + patternId: skillPatterns.missing_frontmatter.id + }); + } + + const { frontmatter } = parseMarkdownFrontmatter(content); + const missingName = skillPatterns.missing_name.check(frontmatter); + if (missingName) { + results.structureIssues.push({ + ...missingName, + file: skillPath, + certainty: skillPatterns.missing_name.certainty, + patternId: skillPatterns.missing_name.id + }); + } + + const missingDescription = skillPatterns.missing_description.check(frontmatter); + if (missingDescription) { + results.structureIssues.push({ + ...missingDescription, + file: skillPath, + certainty: skillPatterns.missing_description.certainty, + patternId: skillPatterns.missing_description.id + }); + } + + const missingTrigger = skillPatterns.missing_trigger_phrase.check(frontmatter); + if (missingTrigger) { + results.triggerIssues.push({ + ...missingTrigger, + file: skillPath, + certainty: skillPatterns.missing_trigger_phrase.certainty, + patternId: skillPatterns.missing_trigger_phrase.id + }); + } + + return results; +} + +function analyzeAllSkills(skillsDir) { + const results = []; + if (!fs.existsSync(skillsDir)) return results; + + const skillFiles = []; + const skipDirs = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'target']); + + function walk(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (err) { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!skipDirs.has(entry.name)) { + walk(fullPath); + } + continue; + } + + if (entry.isFile() && entry.name === 'SKILL.md') { + skillFiles.push(fullPath); + } + } + } + + walk(skillsDir); + + for (const skillPath of skillFiles) { + results.push(analyzeSkill(skillPath)); + } + + return results; +} + +function analyze(options = {}) { + const { + skill, + skillsDir = 'plugins/enhance/skills' + } = options; + + if (skill) { + const skillPath = skill.endsWith('SKILL.md') + ? skill + : path.join(skillsDir, skill, 'SKILL.md'); + return analyzeSkill(skillPath); + } + + return analyzeAllSkills(skillsDir); +} + +module.exports = { + analyzeSkill, + analyzeAllSkills, + analyze +}; diff --git a/plugins/audit-project/lib/enhance/skill-patterns.js b/plugins/audit-project/lib/enhance/skill-patterns.js new file mode 100644 index 00000000..50872c58 --- /dev/null +++ b/plugins/audit-project/lib/enhance/skill-patterns.js @@ -0,0 +1,51 @@ +/** + * Skill patterns for /enhance. + */ + +const skillPatterns = { + missing_frontmatter: { + id: 'missing_frontmatter', + certainty: 'HIGH', + check(content) { + if (!content || !content.trim().startsWith('---')) { + return { issue: 'Missing YAML frontmatter in SKILL.md' }; + } + return null; + } + }, + missing_name: { + id: 'missing_name', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.name) { + return { issue: 'Missing name in SKILL.md frontmatter' }; + } + return null; + } + }, + missing_description: { + id: 'missing_description', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.description) { + return { issue: 'Missing description in SKILL.md frontmatter' }; + } + return null; + } + }, + missing_trigger_phrase: { + id: 'missing_trigger_phrase', + certainty: 'MEDIUM', + check(frontmatter) { + if (!frontmatter || !frontmatter.description) return null; + if (!/use when user asks/i.test(frontmatter.description)) { + return { issue: 'Description missing "Use when user asks" trigger phrase' }; + } + return null; + } + } +}; + +module.exports = { + skillPatterns +}; diff --git a/plugins/audit-project/lib/index.js b/plugins/audit-project/lib/index.js index 646eb350..07706b6c 100644 --- a/plugins/audit-project/lib/index.js +++ b/plugins/audit-project/lib/index.js @@ -26,6 +26,7 @@ const policyQuestions = require('./sources/policy-questions'); const crossPlatform = require('./cross-platform'); const enhance = require('./enhance'); const repoMap = require('./repo-map'); +const perf = require('./perf'); /** * Platform detection and verification utilities @@ -228,6 +229,7 @@ module.exports = { xplat, enhance, repoMap, + perf, // Direct module access for backward compatibility detectPlatform, diff --git a/plugins/audit-project/lib/perf/analyzer/index.js b/plugins/audit-project/lib/perf/analyzer/index.js new file mode 100644 index 00000000..87fd5c4f --- /dev/null +++ b/plugins/audit-project/lib/perf/analyzer/index.js @@ -0,0 +1,22 @@ +/** + * Perf analysis helpers. + * + * @module lib/perf/analyzer + */ + +/** + * Build a compact summary of perf findings. + * @param {object} input + * @returns {object} + */ +function summarize(input = {}) { + return { + summary: input.summary || '', + recommendations: input.recommendations || [], + risks: input.risks || [] + }; +} + +module.exports = { + summarize +}; diff --git a/plugins/audit-project/lib/perf/argument-parser.js b/plugins/audit-project/lib/perf/argument-parser.js new file mode 100644 index 00000000..46b04d35 --- /dev/null +++ b/plugins/audit-project/lib/perf/argument-parser.js @@ -0,0 +1,65 @@ +/** + * Argument parsing helper for /perf. + * + * @module lib/perf/argument-parser + */ + +function parseArguments(raw) { + if (!raw || typeof raw !== 'string') return []; + + const args = []; + let current = ''; + let quote = null; + let escaped = false; + + for (let i = 0; i < raw.length; i++) { + const ch = raw[i]; + + if (escaped) { + current += ch; + escaped = false; + continue; + } + + if (ch === '\\') { + if (quote) { + escaped = true; + continue; + } + } + + if (quote) { + if (ch === quote) { + quote = null; + } else { + current += ch; + } + continue; + } + + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + + if (/\s/.test(ch)) { + if (current) { + args.push(current); + current = ''; + } + continue; + } + + current += ch; + } + + if (current) { + args.push(current); + } + + return args; +} + +module.exports = { + parseArguments +}; diff --git a/plugins/audit-project/lib/perf/baseline-comparator.js b/plugins/audit-project/lib/perf/baseline-comparator.js new file mode 100644 index 00000000..7e71220a --- /dev/null +++ b/plugins/audit-project/lib/perf/baseline-comparator.js @@ -0,0 +1,50 @@ +/** + * Baseline comparison helpers + * + * @module lib/perf/baseline-comparator + */ + +/** + * Compute delta between baseline and current metrics. + * Supports flat numeric values under baseline.metrics/current.metrics. + * + * @param {object} baseline + * @param {object} current + * @returns {object} + */ +function compareBaselines(baseline, current) { + const baselineMetrics = baseline?.metrics || {}; + const currentMetrics = current?.metrics || {}; + const keys = new Set([ + ...Object.keys(baselineMetrics), + ...Object.keys(currentMetrics) + ]); + + const deltas = {}; + for (const key of keys) { + const baseValue = baselineMetrics[key]; + const currentValue = currentMetrics[key]; + + if (typeof baseValue === 'number' && typeof currentValue === 'number') { + const delta = currentValue - baseValue; + const percent = baseValue === 0 ? null : delta / baseValue; + deltas[key] = { baseline: baseValue, current: currentValue, delta, percent }; + } else { + deltas[key] = { + baseline: baseValue ?? null, + current: currentValue ?? null, + delta: null, + percent: null + }; + } + } + + return { + comparedAt: new Date().toISOString(), + metrics: deltas + }; +} + +module.exports = { + compareBaselines +}; diff --git a/plugins/audit-project/lib/perf/baseline-store.js b/plugins/audit-project/lib/perf/baseline-store.js new file mode 100644 index 00000000..f8c8a21f --- /dev/null +++ b/plugins/audit-project/lib/perf/baseline-store.js @@ -0,0 +1,127 @@ +/** + * Baseline storage utilities for /perf + * + * Stores baselines under: + * - {state-dir}/perf/baselines/{version}.json + * + * @module lib/perf/baseline-store + */ + +const fs = require('fs'); +const path = require('path'); +const { getStateDir } = require('../platform/state-dir'); +const { validateBaseline, assertValid } = require('./schemas'); + +const BASELINE_DIR = 'baselines'; + +function assertSafeBaselineVersion(version) { + if (!version || typeof version !== 'string') { + throw new Error('Baseline version is required'); + } + if (version.includes('..') || version.includes('/') || version.includes('\\') || version.includes('\0')) { + throw new Error('Baseline version contains invalid characters'); + } + if (!/^[a-zA-Z0-9._+-]+$/.test(version)) { + throw new Error('Baseline version contains invalid characters'); + } + return version; +} + +/** + * Get baseline directory path + * @param {string} basePath + * @returns {string} + */ +function getBaselineDir(basePath = process.cwd()) { + return path.join(basePath, getStateDir(basePath), 'perf', BASELINE_DIR); +} + +/** + * Ensure baseline directory exists + * @param {string} basePath + * @returns {string} + */ +function ensureBaselineDir(basePath = process.cwd()) { + const dir = getBaselineDir(basePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + return dir; +} + +/** + * Build baseline file path + * @param {string} version + * @param {string} basePath + * @returns {string} + */ +function getBaselinePath(version, basePath = process.cwd()) { + const safeVersion = assertSafeBaselineVersion(version); + return path.join(ensureBaselineDir(basePath), `${safeVersion}.json`); +} + +/** + * List baseline versions + * @param {string} basePath + * @returns {string[]} + */ +function listBaselines(basePath = process.cwd()) { + const dir = ensureBaselineDir(basePath); + return fs.readdirSync(dir) + .filter(file => file.endsWith('.json')) + .map(file => path.basename(file, '.json')) + .sort(); +} + +/** + * Read baseline file + * @param {string} version + * @param {string} basePath + * @returns {object|null} + */ +function readBaseline(version, basePath = process.cwd()) { + const baselinePath = getBaselinePath(version, basePath); + if (!fs.existsSync(baselinePath)) { + return null; + } + try { + const parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf8')); + const validation = validateBaseline(parsed); + if (!validation.ok) { + console.error(`[CRITICAL] Invalid baseline file at ${baselinePath}: ${validation.errors.join(', ')}`); + return null; + } + return parsed; + } catch (error) { + console.error(`[CRITICAL] Corrupted baseline file at ${baselinePath}: ${error.message}`); + return null; + } +} + +/** + * Write baseline file (overwrites existing) + * @param {string} version + * @param {object} baseline + * @param {string} basePath + * @returns {boolean} + */ +function writeBaseline(version, baseline, basePath = process.cwd()) { + const baselinePath = getBaselinePath(version, basePath); + const payload = { + version, + recordedAt: new Date().toISOString(), + ...baseline + }; + assertValid(validateBaseline(payload), 'Invalid baseline payload'); + fs.writeFileSync(baselinePath, JSON.stringify(payload, null, 2), 'utf8'); + return true; +} + +module.exports = { + getBaselineDir, + ensureBaselineDir, + getBaselinePath, + listBaselines, + readBaseline, + writeBaseline +}; diff --git a/plugins/audit-project/lib/perf/benchmark-runner.js b/plugins/audit-project/lib/perf/benchmark-runner.js new file mode 100644 index 00000000..c245815c --- /dev/null +++ b/plugins/audit-project/lib/perf/benchmark-runner.js @@ -0,0 +1,107 @@ +/** + * Sequential benchmark runner utilities. + * + * @module lib/perf/benchmark-runner + */ + +const { execSync } = require('child_process'); +const { validateBaseline } = require('./schemas'); + +const DEFAULT_MIN_DURATION = 60; +const BINARY_SEARCH_MIN_DURATION = 30; + +/** + * Normalize benchmark options and enforce minimum durations. + * @param {object} options + * @returns {object} + */ +function normalizeBenchmarkOptions(options = {}) { + const mode = options.mode || 'full'; + const minDuration = mode === 'binary-search' + ? BINARY_SEARCH_MIN_DURATION + : DEFAULT_MIN_DURATION; + + const duration = Math.max(options.duration || minDuration, minDuration); + return { + ...options, + mode, + duration, + warmup: options.warmup || 10 + }; +} + +/** + * Run a benchmark command synchronously (sequential only). + * @param {string} command + * @param {object} options + * @returns {{ success: boolean, output: string }} + */ +function runBenchmark(command, options = {}) { + if (!command || typeof command !== 'string') { + throw new Error('Benchmark command must be a non-empty string'); + } + + const normalized = normalizeBenchmarkOptions(options); + const env = { ...process.env, ...normalized.env }; + + const output = execSync(command, { + stdio: 'pipe', + encoding: 'utf8', + env + }); + + return { + success: true, + output, + duration: normalized.duration, + warmup: normalized.warmup, + mode: normalized.mode + }; +} + +/** + * Parse metrics from benchmark output using PERF_METRICS markers. + * @param {string} output + * @returns {{ ok: boolean, metrics?: object, error?: string }} + */ +function parseMetrics(output) { + if (typeof output !== 'string') { + return { ok: false, error: 'Output must be a string' }; + } + + const startMarker = 'PERF_METRICS_START'; + const endMarker = 'PERF_METRICS_END'; + const startIndex = output.indexOf(startMarker); + const endIndex = output.indexOf(endMarker); + + if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) { + return { ok: false, error: 'Metrics markers not found' }; + } + + const jsonStart = startIndex + startMarker.length; + const raw = output.slice(jsonStart, endIndex).trim(); + + try { + const parsed = JSON.parse(raw); + const validation = validateBaseline({ + version: 'temp', + recordedAt: new Date().toISOString(), + command: 'temp', + metrics: parsed + }); + if (!validation.ok) { + return { ok: false, error: `Invalid metrics: ${validation.errors.join(', ')}` }; + } + return { ok: true, metrics: parsed }; + } catch (error) { + return { ok: false, error: `Failed to parse metrics JSON: ${error.message}` }; + } +} + +module.exports = { + DEFAULT_MIN_DURATION, + BINARY_SEARCH_MIN_DURATION, + normalizeBenchmarkOptions, + runBenchmark, + parseMetrics +}; diff --git a/plugins/audit-project/lib/perf/breaking-point-finder.js b/plugins/audit-project/lib/perf/breaking-point-finder.js new file mode 100644 index 00000000..d7239cce --- /dev/null +++ b/plugins/audit-project/lib/perf/breaking-point-finder.js @@ -0,0 +1,52 @@ +/** + * Binary search helper for breaking point discovery. + * + * @module lib/perf/breaking-point-finder + */ + +/** + * Find breaking point using binary search. + * The runner should return { ok: boolean, data?: any }. + * + * @param {object} options + * @param {number} options.min + * @param {number} options.max + * @param {(value:number)=>Promise<{ok:boolean,data?:any}>} options.runner + * @returns {Promise<{breakingPoint:number|null, attempts:number, history:Array}>} + */ +async function findBreakingPoint({ min, max, runner }) { + if (typeof min !== 'number' || typeof max !== 'number') { + throw new Error('min and max must be numbers'); + } + if (typeof runner !== 'function') { + throw new Error('runner must be a function'); + } + + let low = min; + let high = max; + let breakingPoint = null; + const history = []; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const result = await runner(mid); + history.push({ value: mid, ok: result.ok }); + + if (result.ok) { + low = mid + 1; + } else { + breakingPoint = mid; + high = mid - 1; + } + } + + return { + breakingPoint, + attempts: history.length, + history + }; +} + +module.exports = { + findBreakingPoint +}; diff --git a/plugins/audit-project/lib/perf/breaking-point-runner.js b/plugins/audit-project/lib/perf/breaking-point-runner.js new file mode 100644 index 00000000..0f15d5af --- /dev/null +++ b/plugins/audit-project/lib/perf/breaking-point-runner.js @@ -0,0 +1,60 @@ +/** + * Breaking point runner wrapper for /perf. + * + * @module lib/perf/breaking-point-runner + */ + +const { runBenchmark, parseMetrics, BINARY_SEARCH_MIN_DURATION } = require('./benchmark-runner'); +const { findBreakingPoint } = require('./breaking-point-finder'); + +/** + * Run a binary search to find the breaking point for a numeric parameter. + * The benchmark command should accept the value via an env var. + * + * @param {object} options + * @param {string} options.command + * @param {string} options.paramEnv + * @param {number} options.min + * @param {number} options.max + * @returns {Promise<{breakingPoint:number|null, attempts:number, history:Array}>} + */ +async function runBreakingPointSearch(options) { + const { command, paramEnv, min, max } = options || {}; + + if (!command || typeof command !== 'string') { + throw new Error('command must be a non-empty string'); + } + if (!paramEnv || typeof paramEnv !== 'string') { + throw new Error('paramEnv must be a non-empty string'); + } + if (typeof min !== 'number' || typeof max !== 'number') { + throw new Error('min and max must be numbers'); + } + + const runner = async (value) => { + try { + const result = runBenchmark(command, { + mode: 'binary-search', + duration: BINARY_SEARCH_MIN_DURATION, + env: { + [paramEnv]: String(value) + } + }); + + const parsed = parseMetrics(result.output); + if (!parsed.ok) { + return { ok: false, data: { error: parsed.error } }; + } + + return { ok: true, data: { metrics: parsed.metrics } }; + } catch (error) { + return { ok: false, data: { error: error.message } }; + } + }; + + return findBreakingPoint({ min, max, runner }); +} + +module.exports = { + runBreakingPointSearch +}; diff --git a/plugins/audit-project/lib/perf/checkpoint.js b/plugins/audit-project/lib/perf/checkpoint.js new file mode 100644 index 00000000..8926f855 --- /dev/null +++ b/plugins/audit-project/lib/perf/checkpoint.js @@ -0,0 +1,99 @@ +/** + * Git checkpoint helper for /perf phases. + * + * @module lib/perf/checkpoint + */ + +const { execSync, execFileSync } = require('child_process'); + +/** + * Check if git repo is clean. + * @returns {boolean} + */ +function isWorkingTreeClean() { + const output = execSync('git status --porcelain', { encoding: 'utf8' }).trim(); + return output.length === 0; +} + +/** + * Build checkpoint commit message. + * @param {object} input + * @param {string} input.phase + * @param {string} input.id + * @param {string} [input.baselineVersion] + * @param {string} [input.deltaSummary] + * @returns {string} + */ +function buildCheckpointMessage(input) { + if (!input || typeof input !== 'object') { + throw new Error('Checkpoint input must be an object'); + } + const { phase, id, baselineVersion, deltaSummary } = input; + + if (!phase || typeof phase !== 'string') { + throw new Error('phase is required'); + } + if (!id || typeof id !== 'string') { + throw new Error('id is required'); + } + + const baseline = baselineVersion || 'n/a'; + const delta = deltaSummary || 'n/a'; + return `perf: phase ${phase} [${id}] baseline=${baseline} delta=${delta}`; +} + +/** + * Get the most recent git commit message. + * @returns {string|null} + */ +function getLastCommitMessage() { + try { + return execSync('git log -1 --pretty=%B', { encoding: 'utf8' }).trim(); + } catch { + return null; + } +} + +/** + * Check if the next checkpoint would duplicate the last commit. + * @param {string} message + * @returns {boolean} + */ +function isDuplicateCheckpoint(message) { + const last = getLastCommitMessage(); + if (!last) return false; + return last.trim() === String(message || '').trim(); +} + +/** + * Commit a checkpoint for a perf phase. + * @param {object} input + * @returns {{ ok: boolean, message?: string, reason?: string }} + */ +function commitCheckpoint(input) { + try { + execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' }); + } catch { + return { ok: false, reason: 'not a git repo' }; + } + + if (isWorkingTreeClean()) { + return { ok: false, reason: 'nothing to commit' }; + } + + const message = buildCheckpointMessage(input); + if (isDuplicateCheckpoint(message)) { + return { ok: false, reason: 'duplicate checkpoint' }; + } + execFileSync('git', ['add', '-A'], { stdio: 'ignore' }); + execFileSync('git', ['commit', '-m', message], { stdio: 'ignore' }); + return { ok: true, message }; +} + +module.exports = { + isWorkingTreeClean, + buildCheckpointMessage, + getLastCommitMessage, + isDuplicateCheckpoint, + commitCheckpoint +}; diff --git a/plugins/audit-project/lib/perf/code-paths.js b/plugins/audit-project/lib/perf/code-paths.js new file mode 100644 index 00000000..ece2c8bf --- /dev/null +++ b/plugins/audit-project/lib/perf/code-paths.js @@ -0,0 +1,86 @@ +/** + * Code-path discovery helpers for /perf. + * + * @module lib/perf/code-paths + */ + +const DEFAULT_STOPWORDS = new Set([ + 'the', 'and', 'for', 'with', 'from', 'that', 'this', 'these', 'those', + 'into', 'over', 'under', 'than', 'then', 'when', 'where', 'what', 'which', + 'your', 'you', 'our', 'their', 'there', 'have', 'has', 'had', 'will', + 'would', 'should', 'could', 'about', 'across', 'after', 'before', 'while', + 'perf', 'performance', 'investigation', 'baseline', 'benchmark', 'scenario' +]); + +function normalizeKeywords(text) { + if (!text || typeof text !== 'string') return []; + const tokens = text + .toLowerCase() + .split(/[^a-z0-9]+/g) + .filter(Boolean) + .filter(token => token.length > 2) + .filter(token => !DEFAULT_STOPWORDS.has(token)); + + return Array.from(new Set(tokens)); +} + +function scoreEntry(entry, keywords) { + let score = 0; + if (!entry || keywords.length === 0) return score; + + const haystack = [ + entry.file || '', + ...(entry.symbols || []) + ].join(' ').toLowerCase(); + + for (const keyword of keywords) { + if (haystack.includes(keyword)) score += 1; + } + + return score; +} + +function extractSymbols(fileData) { + if (!fileData || !fileData.symbols) return []; + const symbols = []; + for (const group of Object.values(fileData.symbols)) { + if (!Array.isArray(group)) continue; + for (const symbol of group) { + if (symbol && symbol.name) symbols.push(symbol.name); + } + } + return symbols; +} + +function collectCodePaths(repoMap, scenario, limit = 12) { + if (!repoMap || !repoMap.files) { + return { keywords: normalizeKeywords(scenario), paths: [] }; + } + + const keywords = normalizeKeywords(scenario); + const candidates = []; + + for (const [file, data] of Object.entries(repoMap.files)) { + const symbols = extractSymbols(data); + const entry = { file, symbols }; + const score = scoreEntry(entry, keywords); + if (score <= 0) continue; + candidates.push({ ...entry, score }); + } + + candidates.sort((a, b) => b.score - a.score || a.file.localeCompare(b.file)); + + return { + keywords, + paths: candidates.slice(0, limit).map(item => ({ + file: item.file, + score: item.score, + symbols: item.symbols.slice(0, 8) + })) + }; +} + +module.exports = { + normalizeKeywords, + collectCodePaths +}; diff --git a/plugins/audit-project/lib/perf/consolidation.js b/plugins/audit-project/lib/perf/consolidation.js new file mode 100644 index 00000000..f8c292da --- /dev/null +++ b/plugins/audit-project/lib/perf/consolidation.js @@ -0,0 +1,37 @@ +/** + * Baseline consolidation helper. + * + * @module lib/perf/consolidation + */ + +const baselineStore = require('./baseline-store'); + +/** + * Consolidate a baseline for a version (overwrite existing). + * @param {object} input + * @param {string} input.version + * @param {object} input.baseline + * @param {string} [basePath] + * @returns {{ version: string, path: string }} + */ +function consolidateBaseline(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('consolidateBaseline requires an input object'); + } + const { version, baseline } = input; + + if (!version || typeof version !== 'string') { + throw new Error('version is required'); + } + if (!baseline || typeof baseline !== 'object') { + throw new Error('baseline is required'); + } + + baselineStore.writeBaseline(version, baseline, basePath); + const path = baselineStore.getBaselinePath(version, basePath); + return { version, path }; +} + +module.exports = { + consolidateBaseline +}; diff --git a/plugins/audit-project/lib/perf/constraint-runner.js b/plugins/audit-project/lib/perf/constraint-runner.js new file mode 100644 index 00000000..a5c5f6a5 --- /dev/null +++ b/plugins/audit-project/lib/perf/constraint-runner.js @@ -0,0 +1,69 @@ +/** + * Constraint testing runner for /perf. + * + * @module lib/perf/constraint-runner + */ + +const { runBenchmark, parseMetrics, DEFAULT_MIN_DURATION } = require('./benchmark-runner'); +const { compareBaselines } = require('./baseline-comparator'); + +/** + * Run baseline and constrained benchmarks sequentially. + * Constraints are provided via env vars to keep it cross-platform. + * + * @param {object} options + * @param {string} options.command + * @param {object} options.constraints + * @param {object} [options.env] + * @returns {{ constraints: object, baseline: object, constrained: object, delta: object }} + */ +function runConstraintTest(options) { + const { command, constraints, env } = options || {}; + + if (!command || typeof command !== 'string') { + throw new Error('command must be a non-empty string'); + } + if (!constraints || typeof constraints !== 'object' || Array.isArray(constraints)) { + throw new Error('constraints must be an object'); + } + + const baselineResult = runBenchmark(command, { + duration: DEFAULT_MIN_DURATION, + env: { + ...env + } + }); + const baselineMetrics = parseMetrics(baselineResult.output); + if (!baselineMetrics.ok) { + throw new Error(`Baseline metrics parse failed: ${baselineMetrics.error}`); + } + + const constrainedResult = runBenchmark(command, { + duration: DEFAULT_MIN_DURATION, + env: { + ...env, + PERF_CPU_LIMIT: constraints.cpu, + PERF_MEMORY_LIMIT: constraints.memory + } + }); + const constrainedMetrics = parseMetrics(constrainedResult.output); + if (!constrainedMetrics.ok) { + throw new Error(`Constrained metrics parse failed: ${constrainedMetrics.error}`); + } + + const delta = compareBaselines( + { metrics: baselineMetrics.metrics }, + { metrics: constrainedMetrics.metrics } + ); + + return { + constraints, + baseline: { metrics: baselineMetrics.metrics }, + constrained: { metrics: constrainedMetrics.metrics }, + delta + }; +} + +module.exports = { + runConstraintTest +}; diff --git a/plugins/audit-project/lib/perf/experiment-runner.js b/plugins/audit-project/lib/perf/experiment-runner.js new file mode 100644 index 00000000..fbee670d --- /dev/null +++ b/plugins/audit-project/lib/perf/experiment-runner.js @@ -0,0 +1,32 @@ +/** + * Experiment runner utilities. + * + * @module lib/perf/experiment-runner + */ + +/** + * Run experiments sequentially (never parallel). + * @param {Array} experiments + * @param {(experiment:object)=>Promise} runner + * @returns {Promise<{results:Array}>} + */ +async function runExperiments(experiments, runner) { + if (!Array.isArray(experiments)) { + throw new Error('experiments must be an array'); + } + if (typeof runner !== 'function') { + throw new Error('runner must be a function'); + } + + const results = []; + for (const experiment of experiments) { + const result = await runner(experiment); + results.push(result); + } + + return { results }; +} + +module.exports = { + runExperiments +}; diff --git a/plugins/audit-project/lib/perf/index.js b/plugins/audit-project/lib/perf/index.js new file mode 100644 index 00000000..2a8a689a --- /dev/null +++ b/plugins/audit-project/lib/perf/index.js @@ -0,0 +1,41 @@ +/** + * Performance investigation utilities + * + * @module lib/perf + */ + +const investigationState = require('./investigation-state'); +const baselineStore = require('./baseline-store'); +const baselineComparator = require('./baseline-comparator'); +const benchmarkRunner = require('./benchmark-runner'); +const breakingPointFinder = require('./breaking-point-finder'); +const breakingPointRunner = require('./breaking-point-runner'); +const experimentRunner = require('./experiment-runner'); +const constraintRunner = require('./constraint-runner'); +const checkpoint = require('./checkpoint'); +const profilingRunner = require('./profiling-runner'); +const optimizationRunner = require('./optimization-runner'); +const consolidation = require('./consolidation'); +const profilers = require('./profilers'); +const analyzer = require('./analyzer'); +const argumentParser = require('./argument-parser'); +const codePaths = require('./code-paths'); + +module.exports = { + investigationState, + baselineStore, + baselineComparator, + benchmarkRunner, + breakingPointFinder, + breakingPointRunner, + experimentRunner, + constraintRunner, + checkpoint, + profilingRunner, + optimizationRunner, + consolidation, + profilers, + analyzer, + argumentParser, + codePaths +}; diff --git a/plugins/audit-project/lib/perf/investigation-state.js b/plugins/audit-project/lib/perf/investigation-state.js new file mode 100644 index 00000000..3bb091df --- /dev/null +++ b/plugins/audit-project/lib/perf/investigation-state.js @@ -0,0 +1,788 @@ +/** + * Performance investigation state management + * + * Stores investigation state and logs under the platform-aware state directory: + * - {state-dir}/perf/investigation.json + * - {state-dir}/perf/investigations/{id}.md + * + * @module lib/perf/investigation-state + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { getStateDir } = require('../platform/state-dir'); +const { validateInvestigationState, assertValid } = require('./schemas'); + +const SCHEMA_VERSION = 1; +const INVESTIGATION_FILE = 'investigation.json'; +const LOG_DIR = 'investigations'; +const BASELINE_DIR = 'baselines'; + +const PHASES = [ + 'setup', + 'baseline', + 'breaking-point', + 'constraints', + 'hypotheses', + 'code-paths', + 'profiling', + 'optimization', + 'decision', + 'consolidation' +]; + +/** + * Validate and resolve path to prevent path traversal attacks + * @param {string} basePath - Base directory path + * @returns {string} Validated absolute path + */ +function validatePath(basePath) { + if (typeof basePath !== 'string' || basePath.length === 0) { + throw new Error('Path must be a non-empty string'); + } + const resolved = path.resolve(basePath); + if (resolved.includes('\0')) { + throw new Error('Path contains invalid null byte'); + } + return resolved; +} + +/** + * Validate that target path is within base directory + * @param {string} targetPath - Target file path + * @param {string} basePath - Base directory + */ +function validatePathWithinBase(targetPath, basePath) { + const resolvedTarget = path.resolve(targetPath); + const resolvedBase = path.resolve(basePath); + if (!resolvedTarget.startsWith(resolvedBase + path.sep) && resolvedTarget !== resolvedBase) { + throw new Error('Path traversal detected'); + } +} + +function assertSafeInvestigationId(id) { + if (!id || typeof id !== 'string') { + throw new Error('Investigation id is required'); + } + if (id.includes('..') || id.includes('/') || id.includes('\\') || id.includes('\0')) { + throw new Error('Investigation id contains invalid characters'); + } + if (!/^[a-zA-Z0-9._-]+$/.test(id)) { + throw new Error('Investigation id contains invalid characters'); + } + return id; +} + +/** + * Generate a unique investigation ID + * @returns {string} + */ +function generateInvestigationId() { + const now = new Date(); + const date = now.toISOString().slice(0, 10).replace(/-/g, ''); + const time = now.toISOString().slice(11, 19).replace(/:/g, ''); + const random = crypto.randomBytes(4).toString('hex'); + return `perf-${date}-${time}-${random}`; +} + +/** + * Get perf state directory path + * @param {string} basePath + * @returns {string} + */ +function getPerfDir(basePath = process.cwd()) { + const validatedBase = validatePath(basePath); + const perfDir = path.join(validatedBase, getStateDir(basePath), 'perf'); + validatePathWithinBase(perfDir, validatedBase); + return perfDir; +} + +/** + * Ensure perf directories exist + * @param {string} basePath + * @returns {{ perfDir: string, logDir: string, baselineDir: string }} + */ +function ensurePerfDirs(basePath = process.cwd()) { + const perfDir = getPerfDir(basePath); + const logDir = path.join(perfDir, LOG_DIR); + const baselineDir = path.join(perfDir, BASELINE_DIR); + + if (!fs.existsSync(perfDir)) { + fs.mkdirSync(perfDir, { recursive: true }); + } + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + if (!fs.existsSync(baselineDir)) { + fs.mkdirSync(baselineDir, { recursive: true }); + } + + return { perfDir, logDir, baselineDir }; +} + +/** + * Get path to investigation.json + * @param {string} basePath + * @returns {string} + */ +function getInvestigationPath(basePath = process.cwd()) { + const perfDir = getPerfDir(basePath); + return path.join(perfDir, INVESTIGATION_FILE); +} + +/** + * Get path to investigation log + * @param {string} id + * @param {string} basePath + * @returns {string} + */ +function getInvestigationLogPath(id, basePath = process.cwd()) { + const safeId = assertSafeInvestigationId(id); + const { logDir } = ensurePerfDirs(basePath); + return path.join(logDir, `${safeId}.md`); +} + +/** + * Read investigation.json + * @param {string} basePath + * @returns {object|null} + */ +function readInvestigation(basePath = process.cwd()) { + const investigationPath = getInvestigationPath(basePath); + if (!fs.existsSync(investigationPath)) { + return null; + } + try { + const parsed = JSON.parse(fs.readFileSync(investigationPath, 'utf8')); + const validation = validateInvestigationState(parsed); + if (!validation.ok) { + console.error(`[CRITICAL] Invalid investigation state at ${investigationPath}: ${validation.errors.join(', ')}`); + return null; + } + return parsed; + } catch (error) { + console.error(`[CRITICAL] Corrupted investigation.json at ${investigationPath}: ${error.message}`); + return null; + } +} + +/** + * Write investigation.json + * @param {object} state + * @param {string} basePath + * @returns {boolean} + */ +function writeInvestigation(state, basePath = process.cwd()) { + ensurePerfDirs(basePath); + const investigationPath = getInvestigationPath(basePath); + const nextState = { ...state, updatedAt: new Date().toISOString() }; + assertValid(validateInvestigationState(nextState), 'Invalid investigation state'); + fs.writeFileSync(investigationPath, JSON.stringify(nextState, null, 2), 'utf8'); + return true; +} + +/** + * Update investigation.json with partial updates + * @param {object} updates + * @param {string} basePath + * @returns {object|null} + */ +function updateInvestigation(updates, basePath = process.cwd()) { + const current = readInvestigation(basePath) || {}; + const nextState = { ...current }; + + for (const [key, value] of Object.entries(updates)) { + if (value === null) { + nextState[key] = null; + } else if ( + value && typeof value === 'object' && !Array.isArray(value) && + nextState[key] && typeof nextState[key] === 'object' && !Array.isArray(nextState[key]) + ) { + nextState[key] = { ...nextState[key], ...value }; + } else { + nextState[key] = value; + } + } + + writeInvestigation(nextState, basePath); + return readInvestigation(basePath); +} + +/** + * Initialize a new investigation + * @param {object} options + * @param {string} basePath + * @returns {object} + */ +function initializeInvestigation(options = {}, basePath = process.cwd()) { + const id = options.id || generateInvestigationId(); + const phase = options.phase || PHASES[0]; + + if (!PHASES.includes(phase)) { + throw new Error(`Invalid perf phase: ${phase}`); + } + + const state = { + schemaVersion: SCHEMA_VERSION, + id, + status: 'in_progress', + phase, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + scenario: { + description: options.scenario || '', + metrics: options.metrics || [], + successCriteria: options.successCriteria || '', + scenarios: Array.isArray(options.scenarios) ? options.scenarios : [] + }, + baselines: [], + hypotheses: [], + codePaths: [], + experiments: [], + results: [], + breakingPoint: null, + breakingPointHistory: [], + constraintResults: [], + profilingResults: [], + decision: null + }; + + assertValid(validateInvestigationState(state), 'Invalid initial investigation state'); + writeInvestigation(state, basePath); + return state; +} + +/** + * Append a line to the investigation log + * @param {string} id + * @param {string} content + * @param {string} basePath + */ +function appendInvestigationLog(id, content, basePath = process.cwd()) { + if (!content) return; + const logPath = getInvestigationLogPath(id, basePath); + const entry = content.endsWith('\n') ? content : `${content}\n`; + fs.appendFileSync(logPath, entry, 'utf8'); +} + +/** + * Append a baseline section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.command + * @param {object} input.metrics + * @param {string} input.baselinePath + * @param {string} [input.date] + * @param {string} basePath + */ +function appendBaselineLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendBaselineLog requires an input object'); + } + + const { id, userQuote, command, metrics, baselinePath, date, scenarios } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendBaselineLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendBaselineLog requires a non-empty userQuote'); + } + if (!command || typeof command !== 'string') { + throw new Error('appendBaselineLog requires a non-empty command'); + } + if (!metrics || typeof metrics !== 'object' || Array.isArray(metrics)) { + throw new Error('appendBaselineLog requires a metrics object'); + } + if (!baselinePath || typeof baselinePath !== 'string') { + throw new Error('appendBaselineLog requires a baselinePath'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const metricsText = JSON.stringify(metrics); + const scenarioText = Array.isArray(scenarios) && scenarios.length > 0 + ? scenarios.map((scenario) => scenario.name).filter(Boolean).join(', ') + : ''; + + const entry = [ + `## Baseline - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + scenarioText ? `- Scenarios: ${scenarioText}` : null, + `- Baseline command: \`${command}\``, + `- Metrics: ${metricsText}`, + '', + '**Evidence**', + `- Baseline file: ${baselinePath}`, + '' + ].filter(Boolean).join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a profiling section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.tool + * @param {string} input.command + * @param {string[]} input.artifacts + * @param {string[]} input.hotspots + * @param {string} [input.date] + * @param {string} basePath + */ +function appendProfilingLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendProfilingLog requires an input object'); + } + + const { id, userQuote, tool, command, artifacts, hotspots, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendProfilingLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendProfilingLog requires a non-empty userQuote'); + } + if (!tool || typeof tool !== 'string') { + throw new Error('appendProfilingLog requires a tool'); + } + if (!command || typeof command !== 'string') { + throw new Error('appendProfilingLog requires a command'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const artifactList = Array.isArray(artifacts) ? artifacts : []; + const hotspotList = Array.isArray(hotspots) ? hotspots : []; + + const entry = [ + `## Profiling - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Tool: ${tool}`, + `- Command: \`${command}\``, + '', + '**Evidence**', + artifactList.length ? `- Artifacts: ${artifactList.join(', ')}` : '- Artifacts: n/a', + hotspotList.length ? `- Hotspots: ${hotspotList.join(', ')}` : '- Hotspots: n/a', + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a decision section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.verdict + * @param {string} input.rationale + * @param {string} [input.date] + * @param {string} basePath + */ +function appendDecisionLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendDecisionLog requires an input object'); + } + + const { id, userQuote, verdict, rationale, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendDecisionLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendDecisionLog requires a non-empty userQuote'); + } + if (!verdict || typeof verdict !== 'string') { + throw new Error('appendDecisionLog requires a verdict'); + } + if (!rationale || typeof rationale !== 'string') { + throw new Error('appendDecisionLog requires a rationale'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + + const entry = [ + `## Decision - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Verdict: ${verdict}`, + `- Rationale: ${rationale}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a setup section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.scenario + * @param {string} input.command + * @param {string} input.version + * @param {string} [input.date] + * @param {string} basePath + */ +function appendSetupLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendSetupLog requires an input object'); + } + + const { id, userQuote, scenario, command, version, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendSetupLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendSetupLog requires a non-empty userQuote'); + } + if (!scenario || typeof scenario !== 'string') { + throw new Error('appendSetupLog requires a scenario'); + } + if (!command || typeof command !== 'string') { + throw new Error('appendSetupLog requires a command'); + } + if (!version || typeof version !== 'string') { + throw new Error('appendSetupLog requires a version'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Setup - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Scenario: ${scenario}`, + `- Command: \`${command}\``, + `- Version: ${version}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a breaking point section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.paramEnv + * @param {number} input.min + * @param {number} input.max + * @param {number|null} input.breakingPoint + * @param {string} [input.date] + * @param {string} basePath + */ +function appendBreakingPointLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendBreakingPointLog requires an input object'); + } + const { id, userQuote, paramEnv, min, max, breakingPoint, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendBreakingPointLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendBreakingPointLog requires a non-empty userQuote'); + } + if (!paramEnv || typeof paramEnv !== 'string') { + throw new Error('appendBreakingPointLog requires a paramEnv'); + } + if (typeof min !== 'number' || typeof max !== 'number') { + throw new Error('appendBreakingPointLog requires numeric min/max'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Breaking Point - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Param env: ${paramEnv}`, + `- Range: ${min}..${max}`, + `- Breaking point: ${breakingPoint ?? 'n/a'}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a constraints section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {object} input.constraints + * @param {object} input.delta + * @param {string} [input.date] + * @param {string} basePath + */ +function appendConstraintLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendConstraintLog requires an input object'); + } + const { id, userQuote, constraints, delta, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendConstraintLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendConstraintLog requires a non-empty userQuote'); + } + if (!constraints || typeof constraints !== 'object') { + throw new Error('appendConstraintLog requires constraints'); + } + if (!delta || typeof delta !== 'object') { + throw new Error('appendConstraintLog requires delta'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Constraints - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- CPU: ${constraints.cpu || 'n/a'}`, + `- Memory: ${constraints.memory || 'n/a'}`, + '', + '**Evidence**', + `- Delta: ${JSON.stringify(delta.metrics || {})}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a hypotheses section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {Array} input.hypotheses + * @param {string} [input.date] + * @param {string} basePath + */ +function appendHypothesesLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendHypothesesLog requires an input object'); + } + const { id, userQuote, hypotheses, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendHypothesesLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendHypothesesLog requires a non-empty userQuote'); + } + if (!Array.isArray(hypotheses)) { + throw new Error('appendHypothesesLog requires hypotheses array'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const lines = hypotheses.map((item) => { + if (!item) return null; + const label = item.id ? `${item.id}: ` : ''; + const evidence = item.evidence ? ` (evidence: ${item.evidence})` : ''; + const confidence = item.confidence ? ` [${item.confidence}]` : ''; + return `- ${label}${item.hypothesis || 'n/a'}${confidence}${evidence}`; + }).filter(Boolean); + + const entry = [ + `## Hypotheses - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + lines.length > 0 ? lines.join('\n') : '- n/a', + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a code-paths section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string[]} input.keywords + * @param {Array} input.paths + * @param {string} [input.date] + * @param {string} basePath + */ +function appendCodePathsLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendCodePathsLog requires an input object'); + } + const { id, userQuote, keywords, paths, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendCodePathsLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendCodePathsLog requires a non-empty userQuote'); + } + if (!Array.isArray(paths)) { + throw new Error('appendCodePathsLog requires paths array'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const keywordText = Array.isArray(keywords) && keywords.length > 0 ? keywords.join(', ') : 'n/a'; + const pathLines = paths.map((pathEntry) => { + const file = pathEntry.file || 'n/a'; + const score = typeof pathEntry.score === 'number' ? ` (score: ${pathEntry.score})` : ''; + const symbols = Array.isArray(pathEntry.symbols) && pathEntry.symbols.length > 0 + ? ` [${pathEntry.symbols.join(', ')}]` + : ''; + return `- ${file}${score}${symbols}`; + }); + + const entry = [ + `## Code Paths - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Keywords: ${keywordText}`, + pathLines.length > 0 ? pathLines.join('\n') : '- n/a', + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append an optimization section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.change + * @param {object} input.delta + * @param {string} input.verdict + * @param {string} [input.date] + * @param {string} basePath + */ +function appendOptimizationLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendOptimizationLog requires an input object'); + } + const { id, userQuote, change, delta, verdict, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendOptimizationLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendOptimizationLog requires a non-empty userQuote'); + } + if (!change || typeof change !== 'string') { + throw new Error('appendOptimizationLog requires a change summary'); + } + if (!delta || typeof delta !== 'object') { + throw new Error('appendOptimizationLog requires delta'); + } + if (!verdict || typeof verdict !== 'string') { + throw new Error('appendOptimizationLog requires a verdict'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Optimization - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Change: ${change}`, + `- Verdict: ${verdict}`, + '', + '**Evidence**', + `- Delta: ${JSON.stringify(delta.metrics || {})}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a consolidation section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.version + * @param {string} input.path + * @param {string} [input.date] + * @param {string} basePath + */ +function appendConsolidationLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendConsolidationLog requires an input object'); + } + + const { id, userQuote, version, path, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendConsolidationLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendConsolidationLog requires a non-empty userQuote'); + } + if (!version || typeof version !== 'string') { + throw new Error('appendConsolidationLog requires a version'); + } + if (!path || typeof path !== 'string') { + throw new Error('appendConsolidationLog requires a path'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Consolidation - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Version: ${version}`, + `- Baseline file: ${path}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +module.exports = { + SCHEMA_VERSION, + PHASES, + generateInvestigationId, + getPerfDir, + ensurePerfDirs, + getInvestigationPath, + getInvestigationLogPath, + readInvestigation, + writeInvestigation, + updateInvestigation, + initializeInvestigation, + appendInvestigationLog, + appendBaselineLog, + appendProfilingLog, + appendDecisionLog, + appendSetupLog, + appendBreakingPointLog, + appendConstraintLog, + appendHypothesesLog, + appendCodePathsLog, + appendOptimizationLog, + appendConsolidationLog +}; diff --git a/plugins/audit-project/lib/perf/optimization-runner.js b/plugins/audit-project/lib/perf/optimization-runner.js new file mode 100644 index 00000000..4fb6ca0d --- /dev/null +++ b/plugins/audit-project/lib/perf/optimization-runner.js @@ -0,0 +1,67 @@ +/** + * Optimization runner for /perf experiments. + * + * @module lib/perf/optimization-runner + */ + +const { runBenchmark, parseMetrics, DEFAULT_MIN_DURATION } = require('./benchmark-runner'); +const { compareBaselines } = require('./baseline-comparator'); +const { isWorkingTreeClean } = require('./checkpoint'); + +/** + * Run a single optimization experiment with two benchmark runs. + * NOTE: This helper does not modify code; it assumes the change was applied externally. + * + * @param {object} options + * @param {string} options.command + * @param {string} options.changeSummary + * @param {object} [options.env] + * @returns {{ baseline: object, experiment: object, delta: object, verdict: string, change: string }} + */ +function runOptimizationExperiment(options) { + const { command, changeSummary, env } = options || {}; + + if (!command || typeof command !== 'string') { + throw new Error('command must be a non-empty string'); + } + if (!changeSummary || typeof changeSummary !== 'string') { + throw new Error('changeSummary must be a non-empty string'); + } + + const shouldCheckClean = options?.requireClean !== false; + if (shouldCheckClean && !isWorkingTreeClean()) { + throw new Error('working tree is dirty before experiment'); + } + + const baselineRun = runBenchmark(command, { duration: DEFAULT_MIN_DURATION, env }); + const baselineMetrics = parseMetrics(baselineRun.output); + if (!baselineMetrics.ok) { + throw new Error(`Baseline parse failed: ${baselineMetrics.error}`); + } + + // NOTE: Caller is responsible for applying the experiment change here. + // Warm up the system (caches/JIT) before capturing experiment metrics. + runBenchmark(command, { duration: DEFAULT_MIN_DURATION, env }); + const experimentRun = runBenchmark(command, { duration: DEFAULT_MIN_DURATION, env }); + const experimentMetrics = parseMetrics(experimentRun.output); + if (!experimentMetrics.ok) { + throw new Error(`Experiment parse failed: ${experimentMetrics.error}`); + } + + const delta = compareBaselines( + { metrics: baselineMetrics.metrics }, + { metrics: experimentMetrics.metrics } + ); + + return { + change: changeSummary, + baseline: { metrics: baselineMetrics.metrics }, + experiment: { metrics: experimentMetrics.metrics }, + delta, + verdict: 'inconclusive' + }; +} + +module.exports = { + runOptimizationExperiment +}; diff --git a/plugins/audit-project/lib/perf/profilers/go.js b/plugins/audit-project/lib/perf/profilers/go.js new file mode 100644 index 00000000..9616ae6e --- /dev/null +++ b/plugins/audit-project/lib/perf/profilers/go.js @@ -0,0 +1,22 @@ +/** + * Go pprof helper. + * + * @module lib/perf/profilers/go + */ + +module.exports = { + id: 'pprof', + tool: 'pprof', + buildCommand(options = {}) { + const command = options.command || 'go test'; + const output = options.output || 'cpu.pprof'; + return `${command} -cpuprofile=${output}`; + }, + parseOutput() { + return { + tool: 'pprof', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/audit-project/lib/perf/profilers/index.js b/plugins/audit-project/lib/perf/profilers/index.js new file mode 100644 index 00000000..20e66af9 --- /dev/null +++ b/plugins/audit-project/lib/perf/profilers/index.js @@ -0,0 +1,46 @@ +/** + * Profilers registry for /perf. + * + * @module lib/perf/profilers + */ + +const fs = require('fs'); +const path = require('path'); +const cliEnhancers = require('../../patterns/cli-enhancers'); +const nodeProfiler = require('./node'); +const pythonProfiler = require('./python'); +const goProfiler = require('./go'); +const rustProfiler = require('./rust'); +const javaProfiler = require('./java'); + +function hasJavaIndicators(repoPath) { + const indicators = ['pom.xml', 'build.gradle', 'build.gradle.kts']; + return indicators.some((file) => fs.existsSync(path.join(repoPath, file))); +} + +function selectProfiler(repoPath = process.cwd()) { + const languages = cliEnhancers.detectProjectLanguages(repoPath); + + if (hasJavaIndicators(repoPath)) return javaProfiler; + if (languages.includes('typescript') || languages.includes('javascript')) return nodeProfiler; + if (languages.includes('go')) return goProfiler; + if (languages.includes('python')) return pythonProfiler; + if (languages.includes('rust')) return rustProfiler; + + return nodeProfiler; +} + +function listAvailable() { + return [ + nodeProfiler.id, + javaProfiler.id, + pythonProfiler.id, + goProfiler.id, + rustProfiler.id + ]; +} + +module.exports = { + listAvailable, + selectProfiler +}; diff --git a/plugins/audit-project/lib/perf/profilers/java.js b/plugins/audit-project/lib/perf/profilers/java.js new file mode 100644 index 00000000..bb464130 --- /dev/null +++ b/plugins/audit-project/lib/perf/profilers/java.js @@ -0,0 +1,23 @@ +/** + * Java JFR profiler helper. + * + * @module lib/perf/profilers/java + */ + +module.exports = { + id: 'jfr', + tool: 'jfr', + buildCommand(options = {}) { + const command = options.command || 'java'; + const output = options.output || 'profile.jfr'; + const duration = options.duration || '60s'; + return `${command} -XX:StartFlightRecording=duration=${duration},filename=${output}`; + }, + parseOutput() { + return { + tool: 'jfr', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/audit-project/lib/perf/profilers/node.js b/plugins/audit-project/lib/perf/profilers/node.js new file mode 100644 index 00000000..95b7a857 --- /dev/null +++ b/plugins/audit-project/lib/perf/profilers/node.js @@ -0,0 +1,22 @@ +/** + * Node.js profiler helper. + * + * @module lib/perf/profilers/node + */ + +module.exports = { + id: 'node', + tool: '--cpu-prof', + buildCommand(options = {}) { + const command = options.command || 'node'; + const output = options.output || 'node.cpuprofile'; + return `${command} --cpu-prof --cpu-prof-name=${output}`; + }, + parseOutput() { + return { + tool: 'node', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/audit-project/lib/perf/profilers/python.js b/plugins/audit-project/lib/perf/profilers/python.js new file mode 100644 index 00000000..98ede075 --- /dev/null +++ b/plugins/audit-project/lib/perf/profilers/python.js @@ -0,0 +1,23 @@ +/** + * Python cProfile helper. + * + * @module lib/perf/profilers/python + */ + +module.exports = { + id: 'cprofile', + tool: 'cProfile', + buildCommand(options = {}) { + const command = options.command || 'python'; + const target = options.target || '-m'; + const output = options.output || 'profile.prof'; + return `${command} -m cProfile -o ${output} ${target}`; + }, + parseOutput() { + return { + tool: 'cprofile', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/audit-project/lib/perf/profilers/rust.js b/plugins/audit-project/lib/perf/profilers/rust.js new file mode 100644 index 00000000..416186d6 --- /dev/null +++ b/plugins/audit-project/lib/perf/profilers/rust.js @@ -0,0 +1,23 @@ +/** + * Rust perf helper (Linux). + * + * @module lib/perf/profilers/rust + */ + +module.exports = { + id: 'perf', + tool: 'perf', + buildCommand(options = {}) { + const command = options.command || 'perf record'; + const output = options.output || 'perf.data'; + const target = options.target || './target/release/app'; + return `${command} -o ${output} ${target}`; + }, + parseOutput() { + return { + tool: 'perf', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/audit-project/lib/perf/profiling-runner.js b/plugins/audit-project/lib/perf/profiling-runner.js new file mode 100644 index 00000000..e1204f25 --- /dev/null +++ b/plugins/audit-project/lib/perf/profiling-runner.js @@ -0,0 +1,48 @@ +/** + * Profiling execution helper. + * + * @module lib/perf/profiling-runner + */ + +const { execSync } = require('child_process'); +const profilers = require('./profilers'); + +/** + * Run a profiling command and return artifacts/hotspots metadata. + * @param {object} options + * @param {string} [options.repoPath] + * @param {object} [options.profileOptions] + * @returns {{ ok: boolean, result?: object, error?: string }} + */ +function runProfiling(options = {}) { + const repoPath = options.repoPath || process.cwd(); + const profiler = profilers.selectProfiler(repoPath); + + if (!profiler || typeof profiler.buildCommand !== 'function') { + return { ok: false, error: 'No profiler available' }; + } + + const command = profiler.buildCommand(options.profileOptions || {}); + try { + execSync(command, { stdio: 'pipe' }); + } catch (error) { + return { ok: false, error: error.message }; + } + + const parsed = typeof profiler.parseOutput === 'function' + ? profiler.parseOutput() + : { tool: profiler.id, hotspots: [], artifacts: [] }; + + const result = { + tool: profiler.id, + command, + hotspots: parsed.hotspots || [], + artifacts: parsed.artifacts || [] + }; + + return { ok: true, result }; +} + +module.exports = { + runProfiling +}; diff --git a/plugins/audit-project/lib/perf/schemas.js b/plugins/audit-project/lib/perf/schemas.js new file mode 100644 index 00000000..8b86761e --- /dev/null +++ b/plugins/audit-project/lib/perf/schemas.js @@ -0,0 +1,140 @@ +/** + * Schema validation helpers for /perf. + * + * @module lib/perf/schemas + */ + +const REQUIRED_INVESTIGATION_FIELDS = ['schemaVersion', 'id', 'status', 'phase', 'scenario']; +const REQUIRED_BASELINE_FIELDS = ['version', 'recordedAt', 'metrics', 'command']; + +function isObject(value) { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function validateInvestigationState(state) { + const errors = []; + + if (!isObject(state)) { + return { ok: false, errors: ['state must be an object'] }; + } + + for (const field of REQUIRED_INVESTIGATION_FIELDS) { + if (!Object.prototype.hasOwnProperty.call(state, field)) { + errors.push(`missing ${field}`); + } + } + + if (typeof state.id !== 'string' || state.id.trim().length === 0) { + errors.push('id must be a non-empty string'); + } + + if (typeof state.phase !== 'string' || state.phase.trim().length === 0) { + errors.push('phase must be a non-empty string'); + } + + if (!isObject(state.scenario)) { + errors.push('scenario must be an object'); + } else { + if (typeof state.scenario.description !== 'string') { + errors.push('scenario.description must be a string'); + } + if (!Array.isArray(state.scenario.metrics)) { + errors.push('scenario.metrics must be an array'); + } + if (typeof state.scenario.successCriteria !== 'string') { + errors.push('scenario.successCriteria must be a string'); + } + if (state.scenario.scenarios != null) { + if (!Array.isArray(state.scenario.scenarios)) { + errors.push('scenario.scenarios must be an array when provided'); + } else { + state.scenario.scenarios.forEach((scenario, index) => { + if (!isObject(scenario)) { + errors.push(`scenario.scenarios[${index}] must be an object`); + return; + } + if (typeof scenario.name !== 'string' || scenario.name.trim().length === 0) { + errors.push(`scenario.scenarios[${index}].name must be a non-empty string`); + } + if (scenario.params != null && !isObject(scenario.params)) { + errors.push(`scenario.scenarios[${index}].params must be an object when provided`); + } + }); + } + } + } + + return { ok: errors.length === 0, errors }; +} + +function validateBaseline(baseline) { + const errors = []; + + if (!isObject(baseline)) { + return { ok: false, errors: ['baseline must be an object'] }; + } + + for (const field of REQUIRED_BASELINE_FIELDS) { + if (!Object.prototype.hasOwnProperty.call(baseline, field)) { + errors.push(`missing ${field}`); + } + } + + if (typeof baseline.version !== 'string' || baseline.version.trim().length === 0) { + errors.push('version must be a non-empty string'); + } + + if (typeof baseline.recordedAt !== 'string' || baseline.recordedAt.trim().length === 0) { + errors.push('recordedAt must be an ISO8601 string'); + } + + if (typeof baseline.command !== 'string' || baseline.command.trim().length === 0) { + errors.push('command must be a non-empty string'); + } + + if (!isObject(baseline.metrics)) { + errors.push('metrics must be an object'); + } else { + if (baseline.metrics.scenarios != null) { + if (!isObject(baseline.metrics.scenarios)) { + errors.push('metrics.scenarios must be an object when provided'); + } else { + for (const [scenarioName, scenarioMetrics] of Object.entries(baseline.metrics.scenarios)) { + if (!isObject(scenarioMetrics)) { + errors.push(`metrics.scenarios.${scenarioName} must be an object`); + continue; + } + for (const [key, value] of Object.entries(scenarioMetrics)) { + if (typeof value !== 'number' || Number.isNaN(value)) { + errors.push(`metric ${scenarioName}.${key} must be a number`); + } + } + } + } + } else { + for (const [key, value] of Object.entries(baseline.metrics)) { + if (typeof value !== 'number' || Number.isNaN(value)) { + errors.push(`metric ${key} must be a number`); + } + } + } + } + + if (baseline.env && !isObject(baseline.env)) { + errors.push('env must be an object when provided'); + } + + return { ok: errors.length === 0, errors }; +} + +function assertValid(result, message) { + if (!result.ok) { + throw new Error(`${message}: ${result.errors.join(', ')}`); + } +} + +module.exports = { + validateInvestigationState, + validateBaseline, + assertValid +}; diff --git a/plugins/deslop/lib/enhance/hook-analyzer.js b/plugins/deslop/lib/enhance/hook-analyzer.js new file mode 100644 index 00000000..2530e111 --- /dev/null +++ b/plugins/deslop/lib/enhance/hook-analyzer.js @@ -0,0 +1,135 @@ +/** + * Hook analyzer for /enhance. + */ + +const fs = require('fs'); +const path = require('path'); +const { hookPatterns } = require('./hook-patterns'); +const { parseMarkdownFrontmatter } = require('./agent-analyzer'); + +function analyzeHook(hookPath) { + const results = { + hookName: path.basename(hookPath, '.md'), + hookPath, + structureIssues: [] + }; + + if (!fs.existsSync(hookPath)) { + results.structureIssues.push({ + issue: 'File not found', + file: hookPath, + certainty: 'HIGH', + patternId: 'file_not_found' + }); + return results; + } + + let content = ''; + try { + content = fs.readFileSync(hookPath, 'utf8'); + } catch (err) { + results.structureIssues.push({ + issue: `Failed to read file: ${err.message}`, + file: hookPath, + certainty: 'HIGH', + patternId: 'read_error' + }); + return results; + } + + const missingFm = hookPatterns.missing_frontmatter.check(content); + if (missingFm) { + results.structureIssues.push({ + ...missingFm, + file: hookPath, + certainty: hookPatterns.missing_frontmatter.certainty, + patternId: hookPatterns.missing_frontmatter.id + }); + } + + const { frontmatter } = parseMarkdownFrontmatter(content); + const missingName = hookPatterns.missing_name.check(frontmatter); + if (missingName) { + results.structureIssues.push({ + ...missingName, + file: hookPath, + certainty: hookPatterns.missing_name.certainty, + patternId: hookPatterns.missing_name.id + }); + } + + const missingDescription = hookPatterns.missing_description.check(frontmatter); + if (missingDescription) { + results.structureIssues.push({ + ...missingDescription, + file: hookPath, + certainty: hookPatterns.missing_description.certainty, + patternId: hookPatterns.missing_description.id + }); + } + + return results; +} + +function analyzeAllHooks(hooksDir) { + const results = []; + if (!fs.existsSync(hooksDir)) return results; + + const hookFiles = []; + const skipDirs = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'target']); + + function walk(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (err) { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!skipDirs.has(entry.name)) { + walk(fullPath); + } + continue; + } + + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + const parts = fullPath.split(path.sep); + if (parts.includes('hooks')) { + hookFiles.push(fullPath); + } + } + } + + walk(hooksDir); + + for (const file of hookFiles) { + results.push(analyzeHook(file)); + } + + return results; +} + +function analyze(options = {}) { + const { + hook, + hooksDir = 'plugins/enhance/hooks' + } = options; + + if (hook) { + const hookPath = hook.endsWith('.md') + ? hook + : path.join(hooksDir, `${hook}.md`); + return analyzeHook(hookPath); + } + + return analyzeAllHooks(hooksDir); +} + +module.exports = { + analyzeHook, + analyzeAllHooks, + analyze +}; diff --git a/plugins/deslop/lib/enhance/hook-patterns.js b/plugins/deslop/lib/enhance/hook-patterns.js new file mode 100644 index 00000000..472c789b --- /dev/null +++ b/plugins/deslop/lib/enhance/hook-patterns.js @@ -0,0 +1,40 @@ +/** + * Hook patterns for /enhance. + */ + +const hookPatterns = { + missing_frontmatter: { + id: 'missing_frontmatter', + certainty: 'HIGH', + check(content) { + if (!content || !content.trim().startsWith('---')) { + return { issue: 'Missing YAML frontmatter in hook file' }; + } + return null; + } + }, + missing_name: { + id: 'missing_name', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.name) { + return { issue: 'Missing name in hook frontmatter' }; + } + return null; + } + }, + missing_description: { + id: 'missing_description', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.description) { + return { issue: 'Missing description in hook frontmatter' }; + } + return null; + } + } +}; + +module.exports = { + hookPatterns +}; diff --git a/plugins/deslop/lib/enhance/index.js b/plugins/deslop/lib/enhance/index.js index 542e81fd..07539241 100644 --- a/plugins/deslop/lib/enhance/index.js +++ b/plugins/deslop/lib/enhance/index.js @@ -16,6 +16,8 @@ const projectmemoryAnalyzer = require('./projectmemory-analyzer'); const projectmemoryPatterns = require('./projectmemory-patterns'); const promptAnalyzer = require('./prompt-analyzer'); const promptPatterns = require('./prompt-patterns'); +const hookAnalyzer = require('./hook-analyzer'); +const skillAnalyzer = require('./skill-analyzer'); const reporter = require('./reporter'); const fixer = require('./fixer'); @@ -26,6 +28,8 @@ module.exports = { docsAnalyzer, projectmemoryAnalyzer, promptAnalyzer, + hookAnalyzer, + skillAnalyzer, // Pattern modules pluginPatterns, @@ -72,6 +76,16 @@ module.exports = { promptApplyFixes: promptAnalyzer.applyFixes, promptGenerateReport: promptAnalyzer.generateReport, + // Convenience exports - Hooks + analyzeHook: hookAnalyzer.analyzeHook, + analyzeAllHooks: hookAnalyzer.analyzeAllHooks, + hooksAnalyze: hookAnalyzer.analyze, + + // Convenience exports - Skills + analyzeSkill: skillAnalyzer.analyzeSkill, + analyzeAllSkills: skillAnalyzer.analyzeAllSkills, + skillsAnalyze: skillAnalyzer.analyze, + // Convenience exports - Orchestrator generateOrchestratorReport: reporter.generateOrchestratorReport, deduplicateOrchestratorFindings: reporter.deduplicateOrchestratorFindings diff --git a/plugins/deslop/lib/enhance/reporter.js b/plugins/deslop/lib/enhance/reporter.js index 7016a1f8..77b727c6 100644 --- a/plugins/deslop/lib/enhance/reporter.js +++ b/plugins/deslop/lib/enhance/reporter.js @@ -1091,7 +1091,7 @@ function generateOrchestratorReport(aggregatedResults, options = {}) { lines.push('| Enhancer | HIGH | MEDIUM | LOW | Auto-Fixable |'); lines.push('|----------|------|--------|-----|--------------|'); - const enhancerTypes = ['plugin', 'agent', 'claudemd', 'docs', 'prompt']; + const enhancerTypes = ['plugin', 'agent', 'claudemd', 'docs', 'prompt', 'hooks', 'skills']; let totalHigh = 0, totalMedium = 0, totalLow = 0, totalAutoFix = 0; for (const enhancer of enhancerTypes) { diff --git a/plugins/deslop/lib/enhance/skill-analyzer.js b/plugins/deslop/lib/enhance/skill-analyzer.js new file mode 100644 index 00000000..023ac494 --- /dev/null +++ b/plugins/deslop/lib/enhance/skill-analyzer.js @@ -0,0 +1,144 @@ +/** + * Skill analyzer for /enhance. + */ + +const fs = require('fs'); +const path = require('path'); +const { skillPatterns } = require('./skill-patterns'); +const { parseMarkdownFrontmatter } = require('./agent-analyzer'); + +function analyzeSkill(skillPath) { + const results = { + skillName: path.basename(path.dirname(skillPath)), + skillPath, + structureIssues: [], + triggerIssues: [] + }; + + if (!fs.existsSync(skillPath)) { + results.structureIssues.push({ + issue: 'File not found', + file: skillPath, + certainty: 'HIGH', + patternId: 'file_not_found' + }); + return results; + } + + let content = ''; + try { + content = fs.readFileSync(skillPath, 'utf8'); + } catch (err) { + results.structureIssues.push({ + issue: `Failed to read file: ${err.message}`, + file: skillPath, + certainty: 'HIGH', + patternId: 'read_error' + }); + return results; + } + + const missingFm = skillPatterns.missing_frontmatter.check(content); + if (missingFm) { + results.structureIssues.push({ + ...missingFm, + file: skillPath, + certainty: skillPatterns.missing_frontmatter.certainty, + patternId: skillPatterns.missing_frontmatter.id + }); + } + + const { frontmatter } = parseMarkdownFrontmatter(content); + const missingName = skillPatterns.missing_name.check(frontmatter); + if (missingName) { + results.structureIssues.push({ + ...missingName, + file: skillPath, + certainty: skillPatterns.missing_name.certainty, + patternId: skillPatterns.missing_name.id + }); + } + + const missingDescription = skillPatterns.missing_description.check(frontmatter); + if (missingDescription) { + results.structureIssues.push({ + ...missingDescription, + file: skillPath, + certainty: skillPatterns.missing_description.certainty, + patternId: skillPatterns.missing_description.id + }); + } + + const missingTrigger = skillPatterns.missing_trigger_phrase.check(frontmatter); + if (missingTrigger) { + results.triggerIssues.push({ + ...missingTrigger, + file: skillPath, + certainty: skillPatterns.missing_trigger_phrase.certainty, + patternId: skillPatterns.missing_trigger_phrase.id + }); + } + + return results; +} + +function analyzeAllSkills(skillsDir) { + const results = []; + if (!fs.existsSync(skillsDir)) return results; + + const skillFiles = []; + const skipDirs = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'target']); + + function walk(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (err) { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!skipDirs.has(entry.name)) { + walk(fullPath); + } + continue; + } + + if (entry.isFile() && entry.name === 'SKILL.md') { + skillFiles.push(fullPath); + } + } + } + + walk(skillsDir); + + for (const skillPath of skillFiles) { + results.push(analyzeSkill(skillPath)); + } + + return results; +} + +function analyze(options = {}) { + const { + skill, + skillsDir = 'plugins/enhance/skills' + } = options; + + if (skill) { + const skillPath = skill.endsWith('SKILL.md') + ? skill + : path.join(skillsDir, skill, 'SKILL.md'); + return analyzeSkill(skillPath); + } + + return analyzeAllSkills(skillsDir); +} + +module.exports = { + analyzeSkill, + analyzeAllSkills, + analyze +}; diff --git a/plugins/deslop/lib/enhance/skill-patterns.js b/plugins/deslop/lib/enhance/skill-patterns.js new file mode 100644 index 00000000..50872c58 --- /dev/null +++ b/plugins/deslop/lib/enhance/skill-patterns.js @@ -0,0 +1,51 @@ +/** + * Skill patterns for /enhance. + */ + +const skillPatterns = { + missing_frontmatter: { + id: 'missing_frontmatter', + certainty: 'HIGH', + check(content) { + if (!content || !content.trim().startsWith('---')) { + return { issue: 'Missing YAML frontmatter in SKILL.md' }; + } + return null; + } + }, + missing_name: { + id: 'missing_name', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.name) { + return { issue: 'Missing name in SKILL.md frontmatter' }; + } + return null; + } + }, + missing_description: { + id: 'missing_description', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.description) { + return { issue: 'Missing description in SKILL.md frontmatter' }; + } + return null; + } + }, + missing_trigger_phrase: { + id: 'missing_trigger_phrase', + certainty: 'MEDIUM', + check(frontmatter) { + if (!frontmatter || !frontmatter.description) return null; + if (!/use when user asks/i.test(frontmatter.description)) { + return { issue: 'Description missing "Use when user asks" trigger phrase' }; + } + return null; + } + } +}; + +module.exports = { + skillPatterns +}; diff --git a/plugins/deslop/lib/index.js b/plugins/deslop/lib/index.js index 646eb350..07706b6c 100644 --- a/plugins/deslop/lib/index.js +++ b/plugins/deslop/lib/index.js @@ -26,6 +26,7 @@ const policyQuestions = require('./sources/policy-questions'); const crossPlatform = require('./cross-platform'); const enhance = require('./enhance'); const repoMap = require('./repo-map'); +const perf = require('./perf'); /** * Platform detection and verification utilities @@ -228,6 +229,7 @@ module.exports = { xplat, enhance, repoMap, + perf, // Direct module access for backward compatibility detectPlatform, diff --git a/plugins/deslop/lib/perf/analyzer/index.js b/plugins/deslop/lib/perf/analyzer/index.js new file mode 100644 index 00000000..87fd5c4f --- /dev/null +++ b/plugins/deslop/lib/perf/analyzer/index.js @@ -0,0 +1,22 @@ +/** + * Perf analysis helpers. + * + * @module lib/perf/analyzer + */ + +/** + * Build a compact summary of perf findings. + * @param {object} input + * @returns {object} + */ +function summarize(input = {}) { + return { + summary: input.summary || '', + recommendations: input.recommendations || [], + risks: input.risks || [] + }; +} + +module.exports = { + summarize +}; diff --git a/plugins/deslop/lib/perf/argument-parser.js b/plugins/deslop/lib/perf/argument-parser.js new file mode 100644 index 00000000..46b04d35 --- /dev/null +++ b/plugins/deslop/lib/perf/argument-parser.js @@ -0,0 +1,65 @@ +/** + * Argument parsing helper for /perf. + * + * @module lib/perf/argument-parser + */ + +function parseArguments(raw) { + if (!raw || typeof raw !== 'string') return []; + + const args = []; + let current = ''; + let quote = null; + let escaped = false; + + for (let i = 0; i < raw.length; i++) { + const ch = raw[i]; + + if (escaped) { + current += ch; + escaped = false; + continue; + } + + if (ch === '\\') { + if (quote) { + escaped = true; + continue; + } + } + + if (quote) { + if (ch === quote) { + quote = null; + } else { + current += ch; + } + continue; + } + + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + + if (/\s/.test(ch)) { + if (current) { + args.push(current); + current = ''; + } + continue; + } + + current += ch; + } + + if (current) { + args.push(current); + } + + return args; +} + +module.exports = { + parseArguments +}; diff --git a/plugins/deslop/lib/perf/baseline-comparator.js b/plugins/deslop/lib/perf/baseline-comparator.js new file mode 100644 index 00000000..7e71220a --- /dev/null +++ b/plugins/deslop/lib/perf/baseline-comparator.js @@ -0,0 +1,50 @@ +/** + * Baseline comparison helpers + * + * @module lib/perf/baseline-comparator + */ + +/** + * Compute delta between baseline and current metrics. + * Supports flat numeric values under baseline.metrics/current.metrics. + * + * @param {object} baseline + * @param {object} current + * @returns {object} + */ +function compareBaselines(baseline, current) { + const baselineMetrics = baseline?.metrics || {}; + const currentMetrics = current?.metrics || {}; + const keys = new Set([ + ...Object.keys(baselineMetrics), + ...Object.keys(currentMetrics) + ]); + + const deltas = {}; + for (const key of keys) { + const baseValue = baselineMetrics[key]; + const currentValue = currentMetrics[key]; + + if (typeof baseValue === 'number' && typeof currentValue === 'number') { + const delta = currentValue - baseValue; + const percent = baseValue === 0 ? null : delta / baseValue; + deltas[key] = { baseline: baseValue, current: currentValue, delta, percent }; + } else { + deltas[key] = { + baseline: baseValue ?? null, + current: currentValue ?? null, + delta: null, + percent: null + }; + } + } + + return { + comparedAt: new Date().toISOString(), + metrics: deltas + }; +} + +module.exports = { + compareBaselines +}; diff --git a/plugins/deslop/lib/perf/baseline-store.js b/plugins/deslop/lib/perf/baseline-store.js new file mode 100644 index 00000000..f8c8a21f --- /dev/null +++ b/plugins/deslop/lib/perf/baseline-store.js @@ -0,0 +1,127 @@ +/** + * Baseline storage utilities for /perf + * + * Stores baselines under: + * - {state-dir}/perf/baselines/{version}.json + * + * @module lib/perf/baseline-store + */ + +const fs = require('fs'); +const path = require('path'); +const { getStateDir } = require('../platform/state-dir'); +const { validateBaseline, assertValid } = require('./schemas'); + +const BASELINE_DIR = 'baselines'; + +function assertSafeBaselineVersion(version) { + if (!version || typeof version !== 'string') { + throw new Error('Baseline version is required'); + } + if (version.includes('..') || version.includes('/') || version.includes('\\') || version.includes('\0')) { + throw new Error('Baseline version contains invalid characters'); + } + if (!/^[a-zA-Z0-9._+-]+$/.test(version)) { + throw new Error('Baseline version contains invalid characters'); + } + return version; +} + +/** + * Get baseline directory path + * @param {string} basePath + * @returns {string} + */ +function getBaselineDir(basePath = process.cwd()) { + return path.join(basePath, getStateDir(basePath), 'perf', BASELINE_DIR); +} + +/** + * Ensure baseline directory exists + * @param {string} basePath + * @returns {string} + */ +function ensureBaselineDir(basePath = process.cwd()) { + const dir = getBaselineDir(basePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + return dir; +} + +/** + * Build baseline file path + * @param {string} version + * @param {string} basePath + * @returns {string} + */ +function getBaselinePath(version, basePath = process.cwd()) { + const safeVersion = assertSafeBaselineVersion(version); + return path.join(ensureBaselineDir(basePath), `${safeVersion}.json`); +} + +/** + * List baseline versions + * @param {string} basePath + * @returns {string[]} + */ +function listBaselines(basePath = process.cwd()) { + const dir = ensureBaselineDir(basePath); + return fs.readdirSync(dir) + .filter(file => file.endsWith('.json')) + .map(file => path.basename(file, '.json')) + .sort(); +} + +/** + * Read baseline file + * @param {string} version + * @param {string} basePath + * @returns {object|null} + */ +function readBaseline(version, basePath = process.cwd()) { + const baselinePath = getBaselinePath(version, basePath); + if (!fs.existsSync(baselinePath)) { + return null; + } + try { + const parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf8')); + const validation = validateBaseline(parsed); + if (!validation.ok) { + console.error(`[CRITICAL] Invalid baseline file at ${baselinePath}: ${validation.errors.join(', ')}`); + return null; + } + return parsed; + } catch (error) { + console.error(`[CRITICAL] Corrupted baseline file at ${baselinePath}: ${error.message}`); + return null; + } +} + +/** + * Write baseline file (overwrites existing) + * @param {string} version + * @param {object} baseline + * @param {string} basePath + * @returns {boolean} + */ +function writeBaseline(version, baseline, basePath = process.cwd()) { + const baselinePath = getBaselinePath(version, basePath); + const payload = { + version, + recordedAt: new Date().toISOString(), + ...baseline + }; + assertValid(validateBaseline(payload), 'Invalid baseline payload'); + fs.writeFileSync(baselinePath, JSON.stringify(payload, null, 2), 'utf8'); + return true; +} + +module.exports = { + getBaselineDir, + ensureBaselineDir, + getBaselinePath, + listBaselines, + readBaseline, + writeBaseline +}; diff --git a/plugins/deslop/lib/perf/benchmark-runner.js b/plugins/deslop/lib/perf/benchmark-runner.js new file mode 100644 index 00000000..c245815c --- /dev/null +++ b/plugins/deslop/lib/perf/benchmark-runner.js @@ -0,0 +1,107 @@ +/** + * Sequential benchmark runner utilities. + * + * @module lib/perf/benchmark-runner + */ + +const { execSync } = require('child_process'); +const { validateBaseline } = require('./schemas'); + +const DEFAULT_MIN_DURATION = 60; +const BINARY_SEARCH_MIN_DURATION = 30; + +/** + * Normalize benchmark options and enforce minimum durations. + * @param {object} options + * @returns {object} + */ +function normalizeBenchmarkOptions(options = {}) { + const mode = options.mode || 'full'; + const minDuration = mode === 'binary-search' + ? BINARY_SEARCH_MIN_DURATION + : DEFAULT_MIN_DURATION; + + const duration = Math.max(options.duration || minDuration, minDuration); + return { + ...options, + mode, + duration, + warmup: options.warmup || 10 + }; +} + +/** + * Run a benchmark command synchronously (sequential only). + * @param {string} command + * @param {object} options + * @returns {{ success: boolean, output: string }} + */ +function runBenchmark(command, options = {}) { + if (!command || typeof command !== 'string') { + throw new Error('Benchmark command must be a non-empty string'); + } + + const normalized = normalizeBenchmarkOptions(options); + const env = { ...process.env, ...normalized.env }; + + const output = execSync(command, { + stdio: 'pipe', + encoding: 'utf8', + env + }); + + return { + success: true, + output, + duration: normalized.duration, + warmup: normalized.warmup, + mode: normalized.mode + }; +} + +/** + * Parse metrics from benchmark output using PERF_METRICS markers. + * @param {string} output + * @returns {{ ok: boolean, metrics?: object, error?: string }} + */ +function parseMetrics(output) { + if (typeof output !== 'string') { + return { ok: false, error: 'Output must be a string' }; + } + + const startMarker = 'PERF_METRICS_START'; + const endMarker = 'PERF_METRICS_END'; + const startIndex = output.indexOf(startMarker); + const endIndex = output.indexOf(endMarker); + + if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) { + return { ok: false, error: 'Metrics markers not found' }; + } + + const jsonStart = startIndex + startMarker.length; + const raw = output.slice(jsonStart, endIndex).trim(); + + try { + const parsed = JSON.parse(raw); + const validation = validateBaseline({ + version: 'temp', + recordedAt: new Date().toISOString(), + command: 'temp', + metrics: parsed + }); + if (!validation.ok) { + return { ok: false, error: `Invalid metrics: ${validation.errors.join(', ')}` }; + } + return { ok: true, metrics: parsed }; + } catch (error) { + return { ok: false, error: `Failed to parse metrics JSON: ${error.message}` }; + } +} + +module.exports = { + DEFAULT_MIN_DURATION, + BINARY_SEARCH_MIN_DURATION, + normalizeBenchmarkOptions, + runBenchmark, + parseMetrics +}; diff --git a/plugins/deslop/lib/perf/breaking-point-finder.js b/plugins/deslop/lib/perf/breaking-point-finder.js new file mode 100644 index 00000000..d7239cce --- /dev/null +++ b/plugins/deslop/lib/perf/breaking-point-finder.js @@ -0,0 +1,52 @@ +/** + * Binary search helper for breaking point discovery. + * + * @module lib/perf/breaking-point-finder + */ + +/** + * Find breaking point using binary search. + * The runner should return { ok: boolean, data?: any }. + * + * @param {object} options + * @param {number} options.min + * @param {number} options.max + * @param {(value:number)=>Promise<{ok:boolean,data?:any}>} options.runner + * @returns {Promise<{breakingPoint:number|null, attempts:number, history:Array}>} + */ +async function findBreakingPoint({ min, max, runner }) { + if (typeof min !== 'number' || typeof max !== 'number') { + throw new Error('min and max must be numbers'); + } + if (typeof runner !== 'function') { + throw new Error('runner must be a function'); + } + + let low = min; + let high = max; + let breakingPoint = null; + const history = []; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const result = await runner(mid); + history.push({ value: mid, ok: result.ok }); + + if (result.ok) { + low = mid + 1; + } else { + breakingPoint = mid; + high = mid - 1; + } + } + + return { + breakingPoint, + attempts: history.length, + history + }; +} + +module.exports = { + findBreakingPoint +}; diff --git a/plugins/deslop/lib/perf/breaking-point-runner.js b/plugins/deslop/lib/perf/breaking-point-runner.js new file mode 100644 index 00000000..0f15d5af --- /dev/null +++ b/plugins/deslop/lib/perf/breaking-point-runner.js @@ -0,0 +1,60 @@ +/** + * Breaking point runner wrapper for /perf. + * + * @module lib/perf/breaking-point-runner + */ + +const { runBenchmark, parseMetrics, BINARY_SEARCH_MIN_DURATION } = require('./benchmark-runner'); +const { findBreakingPoint } = require('./breaking-point-finder'); + +/** + * Run a binary search to find the breaking point for a numeric parameter. + * The benchmark command should accept the value via an env var. + * + * @param {object} options + * @param {string} options.command + * @param {string} options.paramEnv + * @param {number} options.min + * @param {number} options.max + * @returns {Promise<{breakingPoint:number|null, attempts:number, history:Array}>} + */ +async function runBreakingPointSearch(options) { + const { command, paramEnv, min, max } = options || {}; + + if (!command || typeof command !== 'string') { + throw new Error('command must be a non-empty string'); + } + if (!paramEnv || typeof paramEnv !== 'string') { + throw new Error('paramEnv must be a non-empty string'); + } + if (typeof min !== 'number' || typeof max !== 'number') { + throw new Error('min and max must be numbers'); + } + + const runner = async (value) => { + try { + const result = runBenchmark(command, { + mode: 'binary-search', + duration: BINARY_SEARCH_MIN_DURATION, + env: { + [paramEnv]: String(value) + } + }); + + const parsed = parseMetrics(result.output); + if (!parsed.ok) { + return { ok: false, data: { error: parsed.error } }; + } + + return { ok: true, data: { metrics: parsed.metrics } }; + } catch (error) { + return { ok: false, data: { error: error.message } }; + } + }; + + return findBreakingPoint({ min, max, runner }); +} + +module.exports = { + runBreakingPointSearch +}; diff --git a/plugins/deslop/lib/perf/checkpoint.js b/plugins/deslop/lib/perf/checkpoint.js new file mode 100644 index 00000000..8926f855 --- /dev/null +++ b/plugins/deslop/lib/perf/checkpoint.js @@ -0,0 +1,99 @@ +/** + * Git checkpoint helper for /perf phases. + * + * @module lib/perf/checkpoint + */ + +const { execSync, execFileSync } = require('child_process'); + +/** + * Check if git repo is clean. + * @returns {boolean} + */ +function isWorkingTreeClean() { + const output = execSync('git status --porcelain', { encoding: 'utf8' }).trim(); + return output.length === 0; +} + +/** + * Build checkpoint commit message. + * @param {object} input + * @param {string} input.phase + * @param {string} input.id + * @param {string} [input.baselineVersion] + * @param {string} [input.deltaSummary] + * @returns {string} + */ +function buildCheckpointMessage(input) { + if (!input || typeof input !== 'object') { + throw new Error('Checkpoint input must be an object'); + } + const { phase, id, baselineVersion, deltaSummary } = input; + + if (!phase || typeof phase !== 'string') { + throw new Error('phase is required'); + } + if (!id || typeof id !== 'string') { + throw new Error('id is required'); + } + + const baseline = baselineVersion || 'n/a'; + const delta = deltaSummary || 'n/a'; + return `perf: phase ${phase} [${id}] baseline=${baseline} delta=${delta}`; +} + +/** + * Get the most recent git commit message. + * @returns {string|null} + */ +function getLastCommitMessage() { + try { + return execSync('git log -1 --pretty=%B', { encoding: 'utf8' }).trim(); + } catch { + return null; + } +} + +/** + * Check if the next checkpoint would duplicate the last commit. + * @param {string} message + * @returns {boolean} + */ +function isDuplicateCheckpoint(message) { + const last = getLastCommitMessage(); + if (!last) return false; + return last.trim() === String(message || '').trim(); +} + +/** + * Commit a checkpoint for a perf phase. + * @param {object} input + * @returns {{ ok: boolean, message?: string, reason?: string }} + */ +function commitCheckpoint(input) { + try { + execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' }); + } catch { + return { ok: false, reason: 'not a git repo' }; + } + + if (isWorkingTreeClean()) { + return { ok: false, reason: 'nothing to commit' }; + } + + const message = buildCheckpointMessage(input); + if (isDuplicateCheckpoint(message)) { + return { ok: false, reason: 'duplicate checkpoint' }; + } + execFileSync('git', ['add', '-A'], { stdio: 'ignore' }); + execFileSync('git', ['commit', '-m', message], { stdio: 'ignore' }); + return { ok: true, message }; +} + +module.exports = { + isWorkingTreeClean, + buildCheckpointMessage, + getLastCommitMessage, + isDuplicateCheckpoint, + commitCheckpoint +}; diff --git a/plugins/deslop/lib/perf/code-paths.js b/plugins/deslop/lib/perf/code-paths.js new file mode 100644 index 00000000..ece2c8bf --- /dev/null +++ b/plugins/deslop/lib/perf/code-paths.js @@ -0,0 +1,86 @@ +/** + * Code-path discovery helpers for /perf. + * + * @module lib/perf/code-paths + */ + +const DEFAULT_STOPWORDS = new Set([ + 'the', 'and', 'for', 'with', 'from', 'that', 'this', 'these', 'those', + 'into', 'over', 'under', 'than', 'then', 'when', 'where', 'what', 'which', + 'your', 'you', 'our', 'their', 'there', 'have', 'has', 'had', 'will', + 'would', 'should', 'could', 'about', 'across', 'after', 'before', 'while', + 'perf', 'performance', 'investigation', 'baseline', 'benchmark', 'scenario' +]); + +function normalizeKeywords(text) { + if (!text || typeof text !== 'string') return []; + const tokens = text + .toLowerCase() + .split(/[^a-z0-9]+/g) + .filter(Boolean) + .filter(token => token.length > 2) + .filter(token => !DEFAULT_STOPWORDS.has(token)); + + return Array.from(new Set(tokens)); +} + +function scoreEntry(entry, keywords) { + let score = 0; + if (!entry || keywords.length === 0) return score; + + const haystack = [ + entry.file || '', + ...(entry.symbols || []) + ].join(' ').toLowerCase(); + + for (const keyword of keywords) { + if (haystack.includes(keyword)) score += 1; + } + + return score; +} + +function extractSymbols(fileData) { + if (!fileData || !fileData.symbols) return []; + const symbols = []; + for (const group of Object.values(fileData.symbols)) { + if (!Array.isArray(group)) continue; + for (const symbol of group) { + if (symbol && symbol.name) symbols.push(symbol.name); + } + } + return symbols; +} + +function collectCodePaths(repoMap, scenario, limit = 12) { + if (!repoMap || !repoMap.files) { + return { keywords: normalizeKeywords(scenario), paths: [] }; + } + + const keywords = normalizeKeywords(scenario); + const candidates = []; + + for (const [file, data] of Object.entries(repoMap.files)) { + const symbols = extractSymbols(data); + const entry = { file, symbols }; + const score = scoreEntry(entry, keywords); + if (score <= 0) continue; + candidates.push({ ...entry, score }); + } + + candidates.sort((a, b) => b.score - a.score || a.file.localeCompare(b.file)); + + return { + keywords, + paths: candidates.slice(0, limit).map(item => ({ + file: item.file, + score: item.score, + symbols: item.symbols.slice(0, 8) + })) + }; +} + +module.exports = { + normalizeKeywords, + collectCodePaths +}; diff --git a/plugins/deslop/lib/perf/consolidation.js b/plugins/deslop/lib/perf/consolidation.js new file mode 100644 index 00000000..f8c292da --- /dev/null +++ b/plugins/deslop/lib/perf/consolidation.js @@ -0,0 +1,37 @@ +/** + * Baseline consolidation helper. + * + * @module lib/perf/consolidation + */ + +const baselineStore = require('./baseline-store'); + +/** + * Consolidate a baseline for a version (overwrite existing). + * @param {object} input + * @param {string} input.version + * @param {object} input.baseline + * @param {string} [basePath] + * @returns {{ version: string, path: string }} + */ +function consolidateBaseline(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('consolidateBaseline requires an input object'); + } + const { version, baseline } = input; + + if (!version || typeof version !== 'string') { + throw new Error('version is required'); + } + if (!baseline || typeof baseline !== 'object') { + throw new Error('baseline is required'); + } + + baselineStore.writeBaseline(version, baseline, basePath); + const path = baselineStore.getBaselinePath(version, basePath); + return { version, path }; +} + +module.exports = { + consolidateBaseline +}; diff --git a/plugins/deslop/lib/perf/constraint-runner.js b/plugins/deslop/lib/perf/constraint-runner.js new file mode 100644 index 00000000..a5c5f6a5 --- /dev/null +++ b/plugins/deslop/lib/perf/constraint-runner.js @@ -0,0 +1,69 @@ +/** + * Constraint testing runner for /perf. + * + * @module lib/perf/constraint-runner + */ + +const { runBenchmark, parseMetrics, DEFAULT_MIN_DURATION } = require('./benchmark-runner'); +const { compareBaselines } = require('./baseline-comparator'); + +/** + * Run baseline and constrained benchmarks sequentially. + * Constraints are provided via env vars to keep it cross-platform. + * + * @param {object} options + * @param {string} options.command + * @param {object} options.constraints + * @param {object} [options.env] + * @returns {{ constraints: object, baseline: object, constrained: object, delta: object }} + */ +function runConstraintTest(options) { + const { command, constraints, env } = options || {}; + + if (!command || typeof command !== 'string') { + throw new Error('command must be a non-empty string'); + } + if (!constraints || typeof constraints !== 'object' || Array.isArray(constraints)) { + throw new Error('constraints must be an object'); + } + + const baselineResult = runBenchmark(command, { + duration: DEFAULT_MIN_DURATION, + env: { + ...env + } + }); + const baselineMetrics = parseMetrics(baselineResult.output); + if (!baselineMetrics.ok) { + throw new Error(`Baseline metrics parse failed: ${baselineMetrics.error}`); + } + + const constrainedResult = runBenchmark(command, { + duration: DEFAULT_MIN_DURATION, + env: { + ...env, + PERF_CPU_LIMIT: constraints.cpu, + PERF_MEMORY_LIMIT: constraints.memory + } + }); + const constrainedMetrics = parseMetrics(constrainedResult.output); + if (!constrainedMetrics.ok) { + throw new Error(`Constrained metrics parse failed: ${constrainedMetrics.error}`); + } + + const delta = compareBaselines( + { metrics: baselineMetrics.metrics }, + { metrics: constrainedMetrics.metrics } + ); + + return { + constraints, + baseline: { metrics: baselineMetrics.metrics }, + constrained: { metrics: constrainedMetrics.metrics }, + delta + }; +} + +module.exports = { + runConstraintTest +}; diff --git a/plugins/deslop/lib/perf/experiment-runner.js b/plugins/deslop/lib/perf/experiment-runner.js new file mode 100644 index 00000000..fbee670d --- /dev/null +++ b/plugins/deslop/lib/perf/experiment-runner.js @@ -0,0 +1,32 @@ +/** + * Experiment runner utilities. + * + * @module lib/perf/experiment-runner + */ + +/** + * Run experiments sequentially (never parallel). + * @param {Array} experiments + * @param {(experiment:object)=>Promise} runner + * @returns {Promise<{results:Array}>} + */ +async function runExperiments(experiments, runner) { + if (!Array.isArray(experiments)) { + throw new Error('experiments must be an array'); + } + if (typeof runner !== 'function') { + throw new Error('runner must be a function'); + } + + const results = []; + for (const experiment of experiments) { + const result = await runner(experiment); + results.push(result); + } + + return { results }; +} + +module.exports = { + runExperiments +}; diff --git a/plugins/deslop/lib/perf/index.js b/plugins/deslop/lib/perf/index.js new file mode 100644 index 00000000..2a8a689a --- /dev/null +++ b/plugins/deslop/lib/perf/index.js @@ -0,0 +1,41 @@ +/** + * Performance investigation utilities + * + * @module lib/perf + */ + +const investigationState = require('./investigation-state'); +const baselineStore = require('./baseline-store'); +const baselineComparator = require('./baseline-comparator'); +const benchmarkRunner = require('./benchmark-runner'); +const breakingPointFinder = require('./breaking-point-finder'); +const breakingPointRunner = require('./breaking-point-runner'); +const experimentRunner = require('./experiment-runner'); +const constraintRunner = require('./constraint-runner'); +const checkpoint = require('./checkpoint'); +const profilingRunner = require('./profiling-runner'); +const optimizationRunner = require('./optimization-runner'); +const consolidation = require('./consolidation'); +const profilers = require('./profilers'); +const analyzer = require('./analyzer'); +const argumentParser = require('./argument-parser'); +const codePaths = require('./code-paths'); + +module.exports = { + investigationState, + baselineStore, + baselineComparator, + benchmarkRunner, + breakingPointFinder, + breakingPointRunner, + experimentRunner, + constraintRunner, + checkpoint, + profilingRunner, + optimizationRunner, + consolidation, + profilers, + analyzer, + argumentParser, + codePaths +}; diff --git a/plugins/deslop/lib/perf/investigation-state.js b/plugins/deslop/lib/perf/investigation-state.js new file mode 100644 index 00000000..3bb091df --- /dev/null +++ b/plugins/deslop/lib/perf/investigation-state.js @@ -0,0 +1,788 @@ +/** + * Performance investigation state management + * + * Stores investigation state and logs under the platform-aware state directory: + * - {state-dir}/perf/investigation.json + * - {state-dir}/perf/investigations/{id}.md + * + * @module lib/perf/investigation-state + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { getStateDir } = require('../platform/state-dir'); +const { validateInvestigationState, assertValid } = require('./schemas'); + +const SCHEMA_VERSION = 1; +const INVESTIGATION_FILE = 'investigation.json'; +const LOG_DIR = 'investigations'; +const BASELINE_DIR = 'baselines'; + +const PHASES = [ + 'setup', + 'baseline', + 'breaking-point', + 'constraints', + 'hypotheses', + 'code-paths', + 'profiling', + 'optimization', + 'decision', + 'consolidation' +]; + +/** + * Validate and resolve path to prevent path traversal attacks + * @param {string} basePath - Base directory path + * @returns {string} Validated absolute path + */ +function validatePath(basePath) { + if (typeof basePath !== 'string' || basePath.length === 0) { + throw new Error('Path must be a non-empty string'); + } + const resolved = path.resolve(basePath); + if (resolved.includes('\0')) { + throw new Error('Path contains invalid null byte'); + } + return resolved; +} + +/** + * Validate that target path is within base directory + * @param {string} targetPath - Target file path + * @param {string} basePath - Base directory + */ +function validatePathWithinBase(targetPath, basePath) { + const resolvedTarget = path.resolve(targetPath); + const resolvedBase = path.resolve(basePath); + if (!resolvedTarget.startsWith(resolvedBase + path.sep) && resolvedTarget !== resolvedBase) { + throw new Error('Path traversal detected'); + } +} + +function assertSafeInvestigationId(id) { + if (!id || typeof id !== 'string') { + throw new Error('Investigation id is required'); + } + if (id.includes('..') || id.includes('/') || id.includes('\\') || id.includes('\0')) { + throw new Error('Investigation id contains invalid characters'); + } + if (!/^[a-zA-Z0-9._-]+$/.test(id)) { + throw new Error('Investigation id contains invalid characters'); + } + return id; +} + +/** + * Generate a unique investigation ID + * @returns {string} + */ +function generateInvestigationId() { + const now = new Date(); + const date = now.toISOString().slice(0, 10).replace(/-/g, ''); + const time = now.toISOString().slice(11, 19).replace(/:/g, ''); + const random = crypto.randomBytes(4).toString('hex'); + return `perf-${date}-${time}-${random}`; +} + +/** + * Get perf state directory path + * @param {string} basePath + * @returns {string} + */ +function getPerfDir(basePath = process.cwd()) { + const validatedBase = validatePath(basePath); + const perfDir = path.join(validatedBase, getStateDir(basePath), 'perf'); + validatePathWithinBase(perfDir, validatedBase); + return perfDir; +} + +/** + * Ensure perf directories exist + * @param {string} basePath + * @returns {{ perfDir: string, logDir: string, baselineDir: string }} + */ +function ensurePerfDirs(basePath = process.cwd()) { + const perfDir = getPerfDir(basePath); + const logDir = path.join(perfDir, LOG_DIR); + const baselineDir = path.join(perfDir, BASELINE_DIR); + + if (!fs.existsSync(perfDir)) { + fs.mkdirSync(perfDir, { recursive: true }); + } + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + if (!fs.existsSync(baselineDir)) { + fs.mkdirSync(baselineDir, { recursive: true }); + } + + return { perfDir, logDir, baselineDir }; +} + +/** + * Get path to investigation.json + * @param {string} basePath + * @returns {string} + */ +function getInvestigationPath(basePath = process.cwd()) { + const perfDir = getPerfDir(basePath); + return path.join(perfDir, INVESTIGATION_FILE); +} + +/** + * Get path to investigation log + * @param {string} id + * @param {string} basePath + * @returns {string} + */ +function getInvestigationLogPath(id, basePath = process.cwd()) { + const safeId = assertSafeInvestigationId(id); + const { logDir } = ensurePerfDirs(basePath); + return path.join(logDir, `${safeId}.md`); +} + +/** + * Read investigation.json + * @param {string} basePath + * @returns {object|null} + */ +function readInvestigation(basePath = process.cwd()) { + const investigationPath = getInvestigationPath(basePath); + if (!fs.existsSync(investigationPath)) { + return null; + } + try { + const parsed = JSON.parse(fs.readFileSync(investigationPath, 'utf8')); + const validation = validateInvestigationState(parsed); + if (!validation.ok) { + console.error(`[CRITICAL] Invalid investigation state at ${investigationPath}: ${validation.errors.join(', ')}`); + return null; + } + return parsed; + } catch (error) { + console.error(`[CRITICAL] Corrupted investigation.json at ${investigationPath}: ${error.message}`); + return null; + } +} + +/** + * Write investigation.json + * @param {object} state + * @param {string} basePath + * @returns {boolean} + */ +function writeInvestigation(state, basePath = process.cwd()) { + ensurePerfDirs(basePath); + const investigationPath = getInvestigationPath(basePath); + const nextState = { ...state, updatedAt: new Date().toISOString() }; + assertValid(validateInvestigationState(nextState), 'Invalid investigation state'); + fs.writeFileSync(investigationPath, JSON.stringify(nextState, null, 2), 'utf8'); + return true; +} + +/** + * Update investigation.json with partial updates + * @param {object} updates + * @param {string} basePath + * @returns {object|null} + */ +function updateInvestigation(updates, basePath = process.cwd()) { + const current = readInvestigation(basePath) || {}; + const nextState = { ...current }; + + for (const [key, value] of Object.entries(updates)) { + if (value === null) { + nextState[key] = null; + } else if ( + value && typeof value === 'object' && !Array.isArray(value) && + nextState[key] && typeof nextState[key] === 'object' && !Array.isArray(nextState[key]) + ) { + nextState[key] = { ...nextState[key], ...value }; + } else { + nextState[key] = value; + } + } + + writeInvestigation(nextState, basePath); + return readInvestigation(basePath); +} + +/** + * Initialize a new investigation + * @param {object} options + * @param {string} basePath + * @returns {object} + */ +function initializeInvestigation(options = {}, basePath = process.cwd()) { + const id = options.id || generateInvestigationId(); + const phase = options.phase || PHASES[0]; + + if (!PHASES.includes(phase)) { + throw new Error(`Invalid perf phase: ${phase}`); + } + + const state = { + schemaVersion: SCHEMA_VERSION, + id, + status: 'in_progress', + phase, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + scenario: { + description: options.scenario || '', + metrics: options.metrics || [], + successCriteria: options.successCriteria || '', + scenarios: Array.isArray(options.scenarios) ? options.scenarios : [] + }, + baselines: [], + hypotheses: [], + codePaths: [], + experiments: [], + results: [], + breakingPoint: null, + breakingPointHistory: [], + constraintResults: [], + profilingResults: [], + decision: null + }; + + assertValid(validateInvestigationState(state), 'Invalid initial investigation state'); + writeInvestigation(state, basePath); + return state; +} + +/** + * Append a line to the investigation log + * @param {string} id + * @param {string} content + * @param {string} basePath + */ +function appendInvestigationLog(id, content, basePath = process.cwd()) { + if (!content) return; + const logPath = getInvestigationLogPath(id, basePath); + const entry = content.endsWith('\n') ? content : `${content}\n`; + fs.appendFileSync(logPath, entry, 'utf8'); +} + +/** + * Append a baseline section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.command + * @param {object} input.metrics + * @param {string} input.baselinePath + * @param {string} [input.date] + * @param {string} basePath + */ +function appendBaselineLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendBaselineLog requires an input object'); + } + + const { id, userQuote, command, metrics, baselinePath, date, scenarios } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendBaselineLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendBaselineLog requires a non-empty userQuote'); + } + if (!command || typeof command !== 'string') { + throw new Error('appendBaselineLog requires a non-empty command'); + } + if (!metrics || typeof metrics !== 'object' || Array.isArray(metrics)) { + throw new Error('appendBaselineLog requires a metrics object'); + } + if (!baselinePath || typeof baselinePath !== 'string') { + throw new Error('appendBaselineLog requires a baselinePath'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const metricsText = JSON.stringify(metrics); + const scenarioText = Array.isArray(scenarios) && scenarios.length > 0 + ? scenarios.map((scenario) => scenario.name).filter(Boolean).join(', ') + : ''; + + const entry = [ + `## Baseline - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + scenarioText ? `- Scenarios: ${scenarioText}` : null, + `- Baseline command: \`${command}\``, + `- Metrics: ${metricsText}`, + '', + '**Evidence**', + `- Baseline file: ${baselinePath}`, + '' + ].filter(Boolean).join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a profiling section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.tool + * @param {string} input.command + * @param {string[]} input.artifacts + * @param {string[]} input.hotspots + * @param {string} [input.date] + * @param {string} basePath + */ +function appendProfilingLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendProfilingLog requires an input object'); + } + + const { id, userQuote, tool, command, artifacts, hotspots, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendProfilingLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendProfilingLog requires a non-empty userQuote'); + } + if (!tool || typeof tool !== 'string') { + throw new Error('appendProfilingLog requires a tool'); + } + if (!command || typeof command !== 'string') { + throw new Error('appendProfilingLog requires a command'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const artifactList = Array.isArray(artifacts) ? artifacts : []; + const hotspotList = Array.isArray(hotspots) ? hotspots : []; + + const entry = [ + `## Profiling - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Tool: ${tool}`, + `- Command: \`${command}\``, + '', + '**Evidence**', + artifactList.length ? `- Artifacts: ${artifactList.join(', ')}` : '- Artifacts: n/a', + hotspotList.length ? `- Hotspots: ${hotspotList.join(', ')}` : '- Hotspots: n/a', + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a decision section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.verdict + * @param {string} input.rationale + * @param {string} [input.date] + * @param {string} basePath + */ +function appendDecisionLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendDecisionLog requires an input object'); + } + + const { id, userQuote, verdict, rationale, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendDecisionLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendDecisionLog requires a non-empty userQuote'); + } + if (!verdict || typeof verdict !== 'string') { + throw new Error('appendDecisionLog requires a verdict'); + } + if (!rationale || typeof rationale !== 'string') { + throw new Error('appendDecisionLog requires a rationale'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + + const entry = [ + `## Decision - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Verdict: ${verdict}`, + `- Rationale: ${rationale}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a setup section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.scenario + * @param {string} input.command + * @param {string} input.version + * @param {string} [input.date] + * @param {string} basePath + */ +function appendSetupLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendSetupLog requires an input object'); + } + + const { id, userQuote, scenario, command, version, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendSetupLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendSetupLog requires a non-empty userQuote'); + } + if (!scenario || typeof scenario !== 'string') { + throw new Error('appendSetupLog requires a scenario'); + } + if (!command || typeof command !== 'string') { + throw new Error('appendSetupLog requires a command'); + } + if (!version || typeof version !== 'string') { + throw new Error('appendSetupLog requires a version'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Setup - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Scenario: ${scenario}`, + `- Command: \`${command}\``, + `- Version: ${version}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a breaking point section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.paramEnv + * @param {number} input.min + * @param {number} input.max + * @param {number|null} input.breakingPoint + * @param {string} [input.date] + * @param {string} basePath + */ +function appendBreakingPointLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendBreakingPointLog requires an input object'); + } + const { id, userQuote, paramEnv, min, max, breakingPoint, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendBreakingPointLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendBreakingPointLog requires a non-empty userQuote'); + } + if (!paramEnv || typeof paramEnv !== 'string') { + throw new Error('appendBreakingPointLog requires a paramEnv'); + } + if (typeof min !== 'number' || typeof max !== 'number') { + throw new Error('appendBreakingPointLog requires numeric min/max'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Breaking Point - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Param env: ${paramEnv}`, + `- Range: ${min}..${max}`, + `- Breaking point: ${breakingPoint ?? 'n/a'}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a constraints section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {object} input.constraints + * @param {object} input.delta + * @param {string} [input.date] + * @param {string} basePath + */ +function appendConstraintLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendConstraintLog requires an input object'); + } + const { id, userQuote, constraints, delta, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendConstraintLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendConstraintLog requires a non-empty userQuote'); + } + if (!constraints || typeof constraints !== 'object') { + throw new Error('appendConstraintLog requires constraints'); + } + if (!delta || typeof delta !== 'object') { + throw new Error('appendConstraintLog requires delta'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Constraints - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- CPU: ${constraints.cpu || 'n/a'}`, + `- Memory: ${constraints.memory || 'n/a'}`, + '', + '**Evidence**', + `- Delta: ${JSON.stringify(delta.metrics || {})}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a hypotheses section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {Array} input.hypotheses + * @param {string} [input.date] + * @param {string} basePath + */ +function appendHypothesesLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendHypothesesLog requires an input object'); + } + const { id, userQuote, hypotheses, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendHypothesesLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendHypothesesLog requires a non-empty userQuote'); + } + if (!Array.isArray(hypotheses)) { + throw new Error('appendHypothesesLog requires hypotheses array'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const lines = hypotheses.map((item) => { + if (!item) return null; + const label = item.id ? `${item.id}: ` : ''; + const evidence = item.evidence ? ` (evidence: ${item.evidence})` : ''; + const confidence = item.confidence ? ` [${item.confidence}]` : ''; + return `- ${label}${item.hypothesis || 'n/a'}${confidence}${evidence}`; + }).filter(Boolean); + + const entry = [ + `## Hypotheses - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + lines.length > 0 ? lines.join('\n') : '- n/a', + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a code-paths section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string[]} input.keywords + * @param {Array} input.paths + * @param {string} [input.date] + * @param {string} basePath + */ +function appendCodePathsLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendCodePathsLog requires an input object'); + } + const { id, userQuote, keywords, paths, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendCodePathsLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendCodePathsLog requires a non-empty userQuote'); + } + if (!Array.isArray(paths)) { + throw new Error('appendCodePathsLog requires paths array'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const keywordText = Array.isArray(keywords) && keywords.length > 0 ? keywords.join(', ') : 'n/a'; + const pathLines = paths.map((pathEntry) => { + const file = pathEntry.file || 'n/a'; + const score = typeof pathEntry.score === 'number' ? ` (score: ${pathEntry.score})` : ''; + const symbols = Array.isArray(pathEntry.symbols) && pathEntry.symbols.length > 0 + ? ` [${pathEntry.symbols.join(', ')}]` + : ''; + return `- ${file}${score}${symbols}`; + }); + + const entry = [ + `## Code Paths - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Keywords: ${keywordText}`, + pathLines.length > 0 ? pathLines.join('\n') : '- n/a', + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append an optimization section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.change + * @param {object} input.delta + * @param {string} input.verdict + * @param {string} [input.date] + * @param {string} basePath + */ +function appendOptimizationLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendOptimizationLog requires an input object'); + } + const { id, userQuote, change, delta, verdict, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendOptimizationLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendOptimizationLog requires a non-empty userQuote'); + } + if (!change || typeof change !== 'string') { + throw new Error('appendOptimizationLog requires a change summary'); + } + if (!delta || typeof delta !== 'object') { + throw new Error('appendOptimizationLog requires delta'); + } + if (!verdict || typeof verdict !== 'string') { + throw new Error('appendOptimizationLog requires a verdict'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Optimization - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Change: ${change}`, + `- Verdict: ${verdict}`, + '', + '**Evidence**', + `- Delta: ${JSON.stringify(delta.metrics || {})}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a consolidation section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.version + * @param {string} input.path + * @param {string} [input.date] + * @param {string} basePath + */ +function appendConsolidationLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendConsolidationLog requires an input object'); + } + + const { id, userQuote, version, path, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendConsolidationLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendConsolidationLog requires a non-empty userQuote'); + } + if (!version || typeof version !== 'string') { + throw new Error('appendConsolidationLog requires a version'); + } + if (!path || typeof path !== 'string') { + throw new Error('appendConsolidationLog requires a path'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Consolidation - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Version: ${version}`, + `- Baseline file: ${path}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +module.exports = { + SCHEMA_VERSION, + PHASES, + generateInvestigationId, + getPerfDir, + ensurePerfDirs, + getInvestigationPath, + getInvestigationLogPath, + readInvestigation, + writeInvestigation, + updateInvestigation, + initializeInvestigation, + appendInvestigationLog, + appendBaselineLog, + appendProfilingLog, + appendDecisionLog, + appendSetupLog, + appendBreakingPointLog, + appendConstraintLog, + appendHypothesesLog, + appendCodePathsLog, + appendOptimizationLog, + appendConsolidationLog +}; diff --git a/plugins/deslop/lib/perf/optimization-runner.js b/plugins/deslop/lib/perf/optimization-runner.js new file mode 100644 index 00000000..4fb6ca0d --- /dev/null +++ b/plugins/deslop/lib/perf/optimization-runner.js @@ -0,0 +1,67 @@ +/** + * Optimization runner for /perf experiments. + * + * @module lib/perf/optimization-runner + */ + +const { runBenchmark, parseMetrics, DEFAULT_MIN_DURATION } = require('./benchmark-runner'); +const { compareBaselines } = require('./baseline-comparator'); +const { isWorkingTreeClean } = require('./checkpoint'); + +/** + * Run a single optimization experiment with two benchmark runs. + * NOTE: This helper does not modify code; it assumes the change was applied externally. + * + * @param {object} options + * @param {string} options.command + * @param {string} options.changeSummary + * @param {object} [options.env] + * @returns {{ baseline: object, experiment: object, delta: object, verdict: string, change: string }} + */ +function runOptimizationExperiment(options) { + const { command, changeSummary, env } = options || {}; + + if (!command || typeof command !== 'string') { + throw new Error('command must be a non-empty string'); + } + if (!changeSummary || typeof changeSummary !== 'string') { + throw new Error('changeSummary must be a non-empty string'); + } + + const shouldCheckClean = options?.requireClean !== false; + if (shouldCheckClean && !isWorkingTreeClean()) { + throw new Error('working tree is dirty before experiment'); + } + + const baselineRun = runBenchmark(command, { duration: DEFAULT_MIN_DURATION, env }); + const baselineMetrics = parseMetrics(baselineRun.output); + if (!baselineMetrics.ok) { + throw new Error(`Baseline parse failed: ${baselineMetrics.error}`); + } + + // NOTE: Caller is responsible for applying the experiment change here. + // Warm up the system (caches/JIT) before capturing experiment metrics. + runBenchmark(command, { duration: DEFAULT_MIN_DURATION, env }); + const experimentRun = runBenchmark(command, { duration: DEFAULT_MIN_DURATION, env }); + const experimentMetrics = parseMetrics(experimentRun.output); + if (!experimentMetrics.ok) { + throw new Error(`Experiment parse failed: ${experimentMetrics.error}`); + } + + const delta = compareBaselines( + { metrics: baselineMetrics.metrics }, + { metrics: experimentMetrics.metrics } + ); + + return { + change: changeSummary, + baseline: { metrics: baselineMetrics.metrics }, + experiment: { metrics: experimentMetrics.metrics }, + delta, + verdict: 'inconclusive' + }; +} + +module.exports = { + runOptimizationExperiment +}; diff --git a/plugins/deslop/lib/perf/profilers/go.js b/plugins/deslop/lib/perf/profilers/go.js new file mode 100644 index 00000000..9616ae6e --- /dev/null +++ b/plugins/deslop/lib/perf/profilers/go.js @@ -0,0 +1,22 @@ +/** + * Go pprof helper. + * + * @module lib/perf/profilers/go + */ + +module.exports = { + id: 'pprof', + tool: 'pprof', + buildCommand(options = {}) { + const command = options.command || 'go test'; + const output = options.output || 'cpu.pprof'; + return `${command} -cpuprofile=${output}`; + }, + parseOutput() { + return { + tool: 'pprof', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/deslop/lib/perf/profilers/index.js b/plugins/deslop/lib/perf/profilers/index.js new file mode 100644 index 00000000..20e66af9 --- /dev/null +++ b/plugins/deslop/lib/perf/profilers/index.js @@ -0,0 +1,46 @@ +/** + * Profilers registry for /perf. + * + * @module lib/perf/profilers + */ + +const fs = require('fs'); +const path = require('path'); +const cliEnhancers = require('../../patterns/cli-enhancers'); +const nodeProfiler = require('./node'); +const pythonProfiler = require('./python'); +const goProfiler = require('./go'); +const rustProfiler = require('./rust'); +const javaProfiler = require('./java'); + +function hasJavaIndicators(repoPath) { + const indicators = ['pom.xml', 'build.gradle', 'build.gradle.kts']; + return indicators.some((file) => fs.existsSync(path.join(repoPath, file))); +} + +function selectProfiler(repoPath = process.cwd()) { + const languages = cliEnhancers.detectProjectLanguages(repoPath); + + if (hasJavaIndicators(repoPath)) return javaProfiler; + if (languages.includes('typescript') || languages.includes('javascript')) return nodeProfiler; + if (languages.includes('go')) return goProfiler; + if (languages.includes('python')) return pythonProfiler; + if (languages.includes('rust')) return rustProfiler; + + return nodeProfiler; +} + +function listAvailable() { + return [ + nodeProfiler.id, + javaProfiler.id, + pythonProfiler.id, + goProfiler.id, + rustProfiler.id + ]; +} + +module.exports = { + listAvailable, + selectProfiler +}; diff --git a/plugins/deslop/lib/perf/profilers/java.js b/plugins/deslop/lib/perf/profilers/java.js new file mode 100644 index 00000000..bb464130 --- /dev/null +++ b/plugins/deslop/lib/perf/profilers/java.js @@ -0,0 +1,23 @@ +/** + * Java JFR profiler helper. + * + * @module lib/perf/profilers/java + */ + +module.exports = { + id: 'jfr', + tool: 'jfr', + buildCommand(options = {}) { + const command = options.command || 'java'; + const output = options.output || 'profile.jfr'; + const duration = options.duration || '60s'; + return `${command} -XX:StartFlightRecording=duration=${duration},filename=${output}`; + }, + parseOutput() { + return { + tool: 'jfr', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/deslop/lib/perf/profilers/node.js b/plugins/deslop/lib/perf/profilers/node.js new file mode 100644 index 00000000..95b7a857 --- /dev/null +++ b/plugins/deslop/lib/perf/profilers/node.js @@ -0,0 +1,22 @@ +/** + * Node.js profiler helper. + * + * @module lib/perf/profilers/node + */ + +module.exports = { + id: 'node', + tool: '--cpu-prof', + buildCommand(options = {}) { + const command = options.command || 'node'; + const output = options.output || 'node.cpuprofile'; + return `${command} --cpu-prof --cpu-prof-name=${output}`; + }, + parseOutput() { + return { + tool: 'node', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/deslop/lib/perf/profilers/python.js b/plugins/deslop/lib/perf/profilers/python.js new file mode 100644 index 00000000..98ede075 --- /dev/null +++ b/plugins/deslop/lib/perf/profilers/python.js @@ -0,0 +1,23 @@ +/** + * Python cProfile helper. + * + * @module lib/perf/profilers/python + */ + +module.exports = { + id: 'cprofile', + tool: 'cProfile', + buildCommand(options = {}) { + const command = options.command || 'python'; + const target = options.target || '-m'; + const output = options.output || 'profile.prof'; + return `${command} -m cProfile -o ${output} ${target}`; + }, + parseOutput() { + return { + tool: 'cprofile', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/deslop/lib/perf/profilers/rust.js b/plugins/deslop/lib/perf/profilers/rust.js new file mode 100644 index 00000000..416186d6 --- /dev/null +++ b/plugins/deslop/lib/perf/profilers/rust.js @@ -0,0 +1,23 @@ +/** + * Rust perf helper (Linux). + * + * @module lib/perf/profilers/rust + */ + +module.exports = { + id: 'perf', + tool: 'perf', + buildCommand(options = {}) { + const command = options.command || 'perf record'; + const output = options.output || 'perf.data'; + const target = options.target || './target/release/app'; + return `${command} -o ${output} ${target}`; + }, + parseOutput() { + return { + tool: 'perf', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/deslop/lib/perf/profiling-runner.js b/plugins/deslop/lib/perf/profiling-runner.js new file mode 100644 index 00000000..e1204f25 --- /dev/null +++ b/plugins/deslop/lib/perf/profiling-runner.js @@ -0,0 +1,48 @@ +/** + * Profiling execution helper. + * + * @module lib/perf/profiling-runner + */ + +const { execSync } = require('child_process'); +const profilers = require('./profilers'); + +/** + * Run a profiling command and return artifacts/hotspots metadata. + * @param {object} options + * @param {string} [options.repoPath] + * @param {object} [options.profileOptions] + * @returns {{ ok: boolean, result?: object, error?: string }} + */ +function runProfiling(options = {}) { + const repoPath = options.repoPath || process.cwd(); + const profiler = profilers.selectProfiler(repoPath); + + if (!profiler || typeof profiler.buildCommand !== 'function') { + return { ok: false, error: 'No profiler available' }; + } + + const command = profiler.buildCommand(options.profileOptions || {}); + try { + execSync(command, { stdio: 'pipe' }); + } catch (error) { + return { ok: false, error: error.message }; + } + + const parsed = typeof profiler.parseOutput === 'function' + ? profiler.parseOutput() + : { tool: profiler.id, hotspots: [], artifacts: [] }; + + const result = { + tool: profiler.id, + command, + hotspots: parsed.hotspots || [], + artifacts: parsed.artifacts || [] + }; + + return { ok: true, result }; +} + +module.exports = { + runProfiling +}; diff --git a/plugins/deslop/lib/perf/schemas.js b/plugins/deslop/lib/perf/schemas.js new file mode 100644 index 00000000..8b86761e --- /dev/null +++ b/plugins/deslop/lib/perf/schemas.js @@ -0,0 +1,140 @@ +/** + * Schema validation helpers for /perf. + * + * @module lib/perf/schemas + */ + +const REQUIRED_INVESTIGATION_FIELDS = ['schemaVersion', 'id', 'status', 'phase', 'scenario']; +const REQUIRED_BASELINE_FIELDS = ['version', 'recordedAt', 'metrics', 'command']; + +function isObject(value) { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function validateInvestigationState(state) { + const errors = []; + + if (!isObject(state)) { + return { ok: false, errors: ['state must be an object'] }; + } + + for (const field of REQUIRED_INVESTIGATION_FIELDS) { + if (!Object.prototype.hasOwnProperty.call(state, field)) { + errors.push(`missing ${field}`); + } + } + + if (typeof state.id !== 'string' || state.id.trim().length === 0) { + errors.push('id must be a non-empty string'); + } + + if (typeof state.phase !== 'string' || state.phase.trim().length === 0) { + errors.push('phase must be a non-empty string'); + } + + if (!isObject(state.scenario)) { + errors.push('scenario must be an object'); + } else { + if (typeof state.scenario.description !== 'string') { + errors.push('scenario.description must be a string'); + } + if (!Array.isArray(state.scenario.metrics)) { + errors.push('scenario.metrics must be an array'); + } + if (typeof state.scenario.successCriteria !== 'string') { + errors.push('scenario.successCriteria must be a string'); + } + if (state.scenario.scenarios != null) { + if (!Array.isArray(state.scenario.scenarios)) { + errors.push('scenario.scenarios must be an array when provided'); + } else { + state.scenario.scenarios.forEach((scenario, index) => { + if (!isObject(scenario)) { + errors.push(`scenario.scenarios[${index}] must be an object`); + return; + } + if (typeof scenario.name !== 'string' || scenario.name.trim().length === 0) { + errors.push(`scenario.scenarios[${index}].name must be a non-empty string`); + } + if (scenario.params != null && !isObject(scenario.params)) { + errors.push(`scenario.scenarios[${index}].params must be an object when provided`); + } + }); + } + } + } + + return { ok: errors.length === 0, errors }; +} + +function validateBaseline(baseline) { + const errors = []; + + if (!isObject(baseline)) { + return { ok: false, errors: ['baseline must be an object'] }; + } + + for (const field of REQUIRED_BASELINE_FIELDS) { + if (!Object.prototype.hasOwnProperty.call(baseline, field)) { + errors.push(`missing ${field}`); + } + } + + if (typeof baseline.version !== 'string' || baseline.version.trim().length === 0) { + errors.push('version must be a non-empty string'); + } + + if (typeof baseline.recordedAt !== 'string' || baseline.recordedAt.trim().length === 0) { + errors.push('recordedAt must be an ISO8601 string'); + } + + if (typeof baseline.command !== 'string' || baseline.command.trim().length === 0) { + errors.push('command must be a non-empty string'); + } + + if (!isObject(baseline.metrics)) { + errors.push('metrics must be an object'); + } else { + if (baseline.metrics.scenarios != null) { + if (!isObject(baseline.metrics.scenarios)) { + errors.push('metrics.scenarios must be an object when provided'); + } else { + for (const [scenarioName, scenarioMetrics] of Object.entries(baseline.metrics.scenarios)) { + if (!isObject(scenarioMetrics)) { + errors.push(`metrics.scenarios.${scenarioName} must be an object`); + continue; + } + for (const [key, value] of Object.entries(scenarioMetrics)) { + if (typeof value !== 'number' || Number.isNaN(value)) { + errors.push(`metric ${scenarioName}.${key} must be a number`); + } + } + } + } + } else { + for (const [key, value] of Object.entries(baseline.metrics)) { + if (typeof value !== 'number' || Number.isNaN(value)) { + errors.push(`metric ${key} must be a number`); + } + } + } + } + + if (baseline.env && !isObject(baseline.env)) { + errors.push('env must be an object when provided'); + } + + return { ok: errors.length === 0, errors }; +} + +function assertValid(result, message) { + if (!result.ok) { + throw new Error(`${message}: ${result.errors.join(', ')}`); + } +} + +module.exports = { + validateInvestigationState, + validateBaseline, + assertValid +}; diff --git a/plugins/drift-detect/lib/enhance/hook-analyzer.js b/plugins/drift-detect/lib/enhance/hook-analyzer.js new file mode 100644 index 00000000..2530e111 --- /dev/null +++ b/plugins/drift-detect/lib/enhance/hook-analyzer.js @@ -0,0 +1,135 @@ +/** + * Hook analyzer for /enhance. + */ + +const fs = require('fs'); +const path = require('path'); +const { hookPatterns } = require('./hook-patterns'); +const { parseMarkdownFrontmatter } = require('./agent-analyzer'); + +function analyzeHook(hookPath) { + const results = { + hookName: path.basename(hookPath, '.md'), + hookPath, + structureIssues: [] + }; + + if (!fs.existsSync(hookPath)) { + results.structureIssues.push({ + issue: 'File not found', + file: hookPath, + certainty: 'HIGH', + patternId: 'file_not_found' + }); + return results; + } + + let content = ''; + try { + content = fs.readFileSync(hookPath, 'utf8'); + } catch (err) { + results.structureIssues.push({ + issue: `Failed to read file: ${err.message}`, + file: hookPath, + certainty: 'HIGH', + patternId: 'read_error' + }); + return results; + } + + const missingFm = hookPatterns.missing_frontmatter.check(content); + if (missingFm) { + results.structureIssues.push({ + ...missingFm, + file: hookPath, + certainty: hookPatterns.missing_frontmatter.certainty, + patternId: hookPatterns.missing_frontmatter.id + }); + } + + const { frontmatter } = parseMarkdownFrontmatter(content); + const missingName = hookPatterns.missing_name.check(frontmatter); + if (missingName) { + results.structureIssues.push({ + ...missingName, + file: hookPath, + certainty: hookPatterns.missing_name.certainty, + patternId: hookPatterns.missing_name.id + }); + } + + const missingDescription = hookPatterns.missing_description.check(frontmatter); + if (missingDescription) { + results.structureIssues.push({ + ...missingDescription, + file: hookPath, + certainty: hookPatterns.missing_description.certainty, + patternId: hookPatterns.missing_description.id + }); + } + + return results; +} + +function analyzeAllHooks(hooksDir) { + const results = []; + if (!fs.existsSync(hooksDir)) return results; + + const hookFiles = []; + const skipDirs = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'target']); + + function walk(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (err) { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!skipDirs.has(entry.name)) { + walk(fullPath); + } + continue; + } + + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + const parts = fullPath.split(path.sep); + if (parts.includes('hooks')) { + hookFiles.push(fullPath); + } + } + } + + walk(hooksDir); + + for (const file of hookFiles) { + results.push(analyzeHook(file)); + } + + return results; +} + +function analyze(options = {}) { + const { + hook, + hooksDir = 'plugins/enhance/hooks' + } = options; + + if (hook) { + const hookPath = hook.endsWith('.md') + ? hook + : path.join(hooksDir, `${hook}.md`); + return analyzeHook(hookPath); + } + + return analyzeAllHooks(hooksDir); +} + +module.exports = { + analyzeHook, + analyzeAllHooks, + analyze +}; diff --git a/plugins/drift-detect/lib/enhance/hook-patterns.js b/plugins/drift-detect/lib/enhance/hook-patterns.js new file mode 100644 index 00000000..472c789b --- /dev/null +++ b/plugins/drift-detect/lib/enhance/hook-patterns.js @@ -0,0 +1,40 @@ +/** + * Hook patterns for /enhance. + */ + +const hookPatterns = { + missing_frontmatter: { + id: 'missing_frontmatter', + certainty: 'HIGH', + check(content) { + if (!content || !content.trim().startsWith('---')) { + return { issue: 'Missing YAML frontmatter in hook file' }; + } + return null; + } + }, + missing_name: { + id: 'missing_name', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.name) { + return { issue: 'Missing name in hook frontmatter' }; + } + return null; + } + }, + missing_description: { + id: 'missing_description', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.description) { + return { issue: 'Missing description in hook frontmatter' }; + } + return null; + } + } +}; + +module.exports = { + hookPatterns +}; diff --git a/plugins/drift-detect/lib/enhance/index.js b/plugins/drift-detect/lib/enhance/index.js index 542e81fd..07539241 100644 --- a/plugins/drift-detect/lib/enhance/index.js +++ b/plugins/drift-detect/lib/enhance/index.js @@ -16,6 +16,8 @@ const projectmemoryAnalyzer = require('./projectmemory-analyzer'); const projectmemoryPatterns = require('./projectmemory-patterns'); const promptAnalyzer = require('./prompt-analyzer'); const promptPatterns = require('./prompt-patterns'); +const hookAnalyzer = require('./hook-analyzer'); +const skillAnalyzer = require('./skill-analyzer'); const reporter = require('./reporter'); const fixer = require('./fixer'); @@ -26,6 +28,8 @@ module.exports = { docsAnalyzer, projectmemoryAnalyzer, promptAnalyzer, + hookAnalyzer, + skillAnalyzer, // Pattern modules pluginPatterns, @@ -72,6 +76,16 @@ module.exports = { promptApplyFixes: promptAnalyzer.applyFixes, promptGenerateReport: promptAnalyzer.generateReport, + // Convenience exports - Hooks + analyzeHook: hookAnalyzer.analyzeHook, + analyzeAllHooks: hookAnalyzer.analyzeAllHooks, + hooksAnalyze: hookAnalyzer.analyze, + + // Convenience exports - Skills + analyzeSkill: skillAnalyzer.analyzeSkill, + analyzeAllSkills: skillAnalyzer.analyzeAllSkills, + skillsAnalyze: skillAnalyzer.analyze, + // Convenience exports - Orchestrator generateOrchestratorReport: reporter.generateOrchestratorReport, deduplicateOrchestratorFindings: reporter.deduplicateOrchestratorFindings diff --git a/plugins/drift-detect/lib/enhance/reporter.js b/plugins/drift-detect/lib/enhance/reporter.js index 7016a1f8..77b727c6 100644 --- a/plugins/drift-detect/lib/enhance/reporter.js +++ b/plugins/drift-detect/lib/enhance/reporter.js @@ -1091,7 +1091,7 @@ function generateOrchestratorReport(aggregatedResults, options = {}) { lines.push('| Enhancer | HIGH | MEDIUM | LOW | Auto-Fixable |'); lines.push('|----------|------|--------|-----|--------------|'); - const enhancerTypes = ['plugin', 'agent', 'claudemd', 'docs', 'prompt']; + const enhancerTypes = ['plugin', 'agent', 'claudemd', 'docs', 'prompt', 'hooks', 'skills']; let totalHigh = 0, totalMedium = 0, totalLow = 0, totalAutoFix = 0; for (const enhancer of enhancerTypes) { diff --git a/plugins/drift-detect/lib/enhance/skill-analyzer.js b/plugins/drift-detect/lib/enhance/skill-analyzer.js new file mode 100644 index 00000000..023ac494 --- /dev/null +++ b/plugins/drift-detect/lib/enhance/skill-analyzer.js @@ -0,0 +1,144 @@ +/** + * Skill analyzer for /enhance. + */ + +const fs = require('fs'); +const path = require('path'); +const { skillPatterns } = require('./skill-patterns'); +const { parseMarkdownFrontmatter } = require('./agent-analyzer'); + +function analyzeSkill(skillPath) { + const results = { + skillName: path.basename(path.dirname(skillPath)), + skillPath, + structureIssues: [], + triggerIssues: [] + }; + + if (!fs.existsSync(skillPath)) { + results.structureIssues.push({ + issue: 'File not found', + file: skillPath, + certainty: 'HIGH', + patternId: 'file_not_found' + }); + return results; + } + + let content = ''; + try { + content = fs.readFileSync(skillPath, 'utf8'); + } catch (err) { + results.structureIssues.push({ + issue: `Failed to read file: ${err.message}`, + file: skillPath, + certainty: 'HIGH', + patternId: 'read_error' + }); + return results; + } + + const missingFm = skillPatterns.missing_frontmatter.check(content); + if (missingFm) { + results.structureIssues.push({ + ...missingFm, + file: skillPath, + certainty: skillPatterns.missing_frontmatter.certainty, + patternId: skillPatterns.missing_frontmatter.id + }); + } + + const { frontmatter } = parseMarkdownFrontmatter(content); + const missingName = skillPatterns.missing_name.check(frontmatter); + if (missingName) { + results.structureIssues.push({ + ...missingName, + file: skillPath, + certainty: skillPatterns.missing_name.certainty, + patternId: skillPatterns.missing_name.id + }); + } + + const missingDescription = skillPatterns.missing_description.check(frontmatter); + if (missingDescription) { + results.structureIssues.push({ + ...missingDescription, + file: skillPath, + certainty: skillPatterns.missing_description.certainty, + patternId: skillPatterns.missing_description.id + }); + } + + const missingTrigger = skillPatterns.missing_trigger_phrase.check(frontmatter); + if (missingTrigger) { + results.triggerIssues.push({ + ...missingTrigger, + file: skillPath, + certainty: skillPatterns.missing_trigger_phrase.certainty, + patternId: skillPatterns.missing_trigger_phrase.id + }); + } + + return results; +} + +function analyzeAllSkills(skillsDir) { + const results = []; + if (!fs.existsSync(skillsDir)) return results; + + const skillFiles = []; + const skipDirs = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'target']); + + function walk(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (err) { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!skipDirs.has(entry.name)) { + walk(fullPath); + } + continue; + } + + if (entry.isFile() && entry.name === 'SKILL.md') { + skillFiles.push(fullPath); + } + } + } + + walk(skillsDir); + + for (const skillPath of skillFiles) { + results.push(analyzeSkill(skillPath)); + } + + return results; +} + +function analyze(options = {}) { + const { + skill, + skillsDir = 'plugins/enhance/skills' + } = options; + + if (skill) { + const skillPath = skill.endsWith('SKILL.md') + ? skill + : path.join(skillsDir, skill, 'SKILL.md'); + return analyzeSkill(skillPath); + } + + return analyzeAllSkills(skillsDir); +} + +module.exports = { + analyzeSkill, + analyzeAllSkills, + analyze +}; diff --git a/plugins/drift-detect/lib/enhance/skill-patterns.js b/plugins/drift-detect/lib/enhance/skill-patterns.js new file mode 100644 index 00000000..50872c58 --- /dev/null +++ b/plugins/drift-detect/lib/enhance/skill-patterns.js @@ -0,0 +1,51 @@ +/** + * Skill patterns for /enhance. + */ + +const skillPatterns = { + missing_frontmatter: { + id: 'missing_frontmatter', + certainty: 'HIGH', + check(content) { + if (!content || !content.trim().startsWith('---')) { + return { issue: 'Missing YAML frontmatter in SKILL.md' }; + } + return null; + } + }, + missing_name: { + id: 'missing_name', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.name) { + return { issue: 'Missing name in SKILL.md frontmatter' }; + } + return null; + } + }, + missing_description: { + id: 'missing_description', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.description) { + return { issue: 'Missing description in SKILL.md frontmatter' }; + } + return null; + } + }, + missing_trigger_phrase: { + id: 'missing_trigger_phrase', + certainty: 'MEDIUM', + check(frontmatter) { + if (!frontmatter || !frontmatter.description) return null; + if (!/use when user asks/i.test(frontmatter.description)) { + return { issue: 'Description missing "Use when user asks" trigger phrase' }; + } + return null; + } + } +}; + +module.exports = { + skillPatterns +}; diff --git a/plugins/drift-detect/lib/index.js b/plugins/drift-detect/lib/index.js index 646eb350..07706b6c 100644 --- a/plugins/drift-detect/lib/index.js +++ b/plugins/drift-detect/lib/index.js @@ -26,6 +26,7 @@ const policyQuestions = require('./sources/policy-questions'); const crossPlatform = require('./cross-platform'); const enhance = require('./enhance'); const repoMap = require('./repo-map'); +const perf = require('./perf'); /** * Platform detection and verification utilities @@ -228,6 +229,7 @@ module.exports = { xplat, enhance, repoMap, + perf, // Direct module access for backward compatibility detectPlatform, diff --git a/plugins/drift-detect/lib/perf/analyzer/index.js b/plugins/drift-detect/lib/perf/analyzer/index.js new file mode 100644 index 00000000..87fd5c4f --- /dev/null +++ b/plugins/drift-detect/lib/perf/analyzer/index.js @@ -0,0 +1,22 @@ +/** + * Perf analysis helpers. + * + * @module lib/perf/analyzer + */ + +/** + * Build a compact summary of perf findings. + * @param {object} input + * @returns {object} + */ +function summarize(input = {}) { + return { + summary: input.summary || '', + recommendations: input.recommendations || [], + risks: input.risks || [] + }; +} + +module.exports = { + summarize +}; diff --git a/plugins/drift-detect/lib/perf/argument-parser.js b/plugins/drift-detect/lib/perf/argument-parser.js new file mode 100644 index 00000000..46b04d35 --- /dev/null +++ b/plugins/drift-detect/lib/perf/argument-parser.js @@ -0,0 +1,65 @@ +/** + * Argument parsing helper for /perf. + * + * @module lib/perf/argument-parser + */ + +function parseArguments(raw) { + if (!raw || typeof raw !== 'string') return []; + + const args = []; + let current = ''; + let quote = null; + let escaped = false; + + for (let i = 0; i < raw.length; i++) { + const ch = raw[i]; + + if (escaped) { + current += ch; + escaped = false; + continue; + } + + if (ch === '\\') { + if (quote) { + escaped = true; + continue; + } + } + + if (quote) { + if (ch === quote) { + quote = null; + } else { + current += ch; + } + continue; + } + + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + + if (/\s/.test(ch)) { + if (current) { + args.push(current); + current = ''; + } + continue; + } + + current += ch; + } + + if (current) { + args.push(current); + } + + return args; +} + +module.exports = { + parseArguments +}; diff --git a/plugins/drift-detect/lib/perf/baseline-comparator.js b/plugins/drift-detect/lib/perf/baseline-comparator.js new file mode 100644 index 00000000..7e71220a --- /dev/null +++ b/plugins/drift-detect/lib/perf/baseline-comparator.js @@ -0,0 +1,50 @@ +/** + * Baseline comparison helpers + * + * @module lib/perf/baseline-comparator + */ + +/** + * Compute delta between baseline and current metrics. + * Supports flat numeric values under baseline.metrics/current.metrics. + * + * @param {object} baseline + * @param {object} current + * @returns {object} + */ +function compareBaselines(baseline, current) { + const baselineMetrics = baseline?.metrics || {}; + const currentMetrics = current?.metrics || {}; + const keys = new Set([ + ...Object.keys(baselineMetrics), + ...Object.keys(currentMetrics) + ]); + + const deltas = {}; + for (const key of keys) { + const baseValue = baselineMetrics[key]; + const currentValue = currentMetrics[key]; + + if (typeof baseValue === 'number' && typeof currentValue === 'number') { + const delta = currentValue - baseValue; + const percent = baseValue === 0 ? null : delta / baseValue; + deltas[key] = { baseline: baseValue, current: currentValue, delta, percent }; + } else { + deltas[key] = { + baseline: baseValue ?? null, + current: currentValue ?? null, + delta: null, + percent: null + }; + } + } + + return { + comparedAt: new Date().toISOString(), + metrics: deltas + }; +} + +module.exports = { + compareBaselines +}; diff --git a/plugins/drift-detect/lib/perf/baseline-store.js b/plugins/drift-detect/lib/perf/baseline-store.js new file mode 100644 index 00000000..f8c8a21f --- /dev/null +++ b/plugins/drift-detect/lib/perf/baseline-store.js @@ -0,0 +1,127 @@ +/** + * Baseline storage utilities for /perf + * + * Stores baselines under: + * - {state-dir}/perf/baselines/{version}.json + * + * @module lib/perf/baseline-store + */ + +const fs = require('fs'); +const path = require('path'); +const { getStateDir } = require('../platform/state-dir'); +const { validateBaseline, assertValid } = require('./schemas'); + +const BASELINE_DIR = 'baselines'; + +function assertSafeBaselineVersion(version) { + if (!version || typeof version !== 'string') { + throw new Error('Baseline version is required'); + } + if (version.includes('..') || version.includes('/') || version.includes('\\') || version.includes('\0')) { + throw new Error('Baseline version contains invalid characters'); + } + if (!/^[a-zA-Z0-9._+-]+$/.test(version)) { + throw new Error('Baseline version contains invalid characters'); + } + return version; +} + +/** + * Get baseline directory path + * @param {string} basePath + * @returns {string} + */ +function getBaselineDir(basePath = process.cwd()) { + return path.join(basePath, getStateDir(basePath), 'perf', BASELINE_DIR); +} + +/** + * Ensure baseline directory exists + * @param {string} basePath + * @returns {string} + */ +function ensureBaselineDir(basePath = process.cwd()) { + const dir = getBaselineDir(basePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + return dir; +} + +/** + * Build baseline file path + * @param {string} version + * @param {string} basePath + * @returns {string} + */ +function getBaselinePath(version, basePath = process.cwd()) { + const safeVersion = assertSafeBaselineVersion(version); + return path.join(ensureBaselineDir(basePath), `${safeVersion}.json`); +} + +/** + * List baseline versions + * @param {string} basePath + * @returns {string[]} + */ +function listBaselines(basePath = process.cwd()) { + const dir = ensureBaselineDir(basePath); + return fs.readdirSync(dir) + .filter(file => file.endsWith('.json')) + .map(file => path.basename(file, '.json')) + .sort(); +} + +/** + * Read baseline file + * @param {string} version + * @param {string} basePath + * @returns {object|null} + */ +function readBaseline(version, basePath = process.cwd()) { + const baselinePath = getBaselinePath(version, basePath); + if (!fs.existsSync(baselinePath)) { + return null; + } + try { + const parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf8')); + const validation = validateBaseline(parsed); + if (!validation.ok) { + console.error(`[CRITICAL] Invalid baseline file at ${baselinePath}: ${validation.errors.join(', ')}`); + return null; + } + return parsed; + } catch (error) { + console.error(`[CRITICAL] Corrupted baseline file at ${baselinePath}: ${error.message}`); + return null; + } +} + +/** + * Write baseline file (overwrites existing) + * @param {string} version + * @param {object} baseline + * @param {string} basePath + * @returns {boolean} + */ +function writeBaseline(version, baseline, basePath = process.cwd()) { + const baselinePath = getBaselinePath(version, basePath); + const payload = { + version, + recordedAt: new Date().toISOString(), + ...baseline + }; + assertValid(validateBaseline(payload), 'Invalid baseline payload'); + fs.writeFileSync(baselinePath, JSON.stringify(payload, null, 2), 'utf8'); + return true; +} + +module.exports = { + getBaselineDir, + ensureBaselineDir, + getBaselinePath, + listBaselines, + readBaseline, + writeBaseline +}; diff --git a/plugins/drift-detect/lib/perf/benchmark-runner.js b/plugins/drift-detect/lib/perf/benchmark-runner.js new file mode 100644 index 00000000..c245815c --- /dev/null +++ b/plugins/drift-detect/lib/perf/benchmark-runner.js @@ -0,0 +1,107 @@ +/** + * Sequential benchmark runner utilities. + * + * @module lib/perf/benchmark-runner + */ + +const { execSync } = require('child_process'); +const { validateBaseline } = require('./schemas'); + +const DEFAULT_MIN_DURATION = 60; +const BINARY_SEARCH_MIN_DURATION = 30; + +/** + * Normalize benchmark options and enforce minimum durations. + * @param {object} options + * @returns {object} + */ +function normalizeBenchmarkOptions(options = {}) { + const mode = options.mode || 'full'; + const minDuration = mode === 'binary-search' + ? BINARY_SEARCH_MIN_DURATION + : DEFAULT_MIN_DURATION; + + const duration = Math.max(options.duration || minDuration, minDuration); + return { + ...options, + mode, + duration, + warmup: options.warmup || 10 + }; +} + +/** + * Run a benchmark command synchronously (sequential only). + * @param {string} command + * @param {object} options + * @returns {{ success: boolean, output: string }} + */ +function runBenchmark(command, options = {}) { + if (!command || typeof command !== 'string') { + throw new Error('Benchmark command must be a non-empty string'); + } + + const normalized = normalizeBenchmarkOptions(options); + const env = { ...process.env, ...normalized.env }; + + const output = execSync(command, { + stdio: 'pipe', + encoding: 'utf8', + env + }); + + return { + success: true, + output, + duration: normalized.duration, + warmup: normalized.warmup, + mode: normalized.mode + }; +} + +/** + * Parse metrics from benchmark output using PERF_METRICS markers. + * @param {string} output + * @returns {{ ok: boolean, metrics?: object, error?: string }} + */ +function parseMetrics(output) { + if (typeof output !== 'string') { + return { ok: false, error: 'Output must be a string' }; + } + + const startMarker = 'PERF_METRICS_START'; + const endMarker = 'PERF_METRICS_END'; + const startIndex = output.indexOf(startMarker); + const endIndex = output.indexOf(endMarker); + + if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) { + return { ok: false, error: 'Metrics markers not found' }; + } + + const jsonStart = startIndex + startMarker.length; + const raw = output.slice(jsonStart, endIndex).trim(); + + try { + const parsed = JSON.parse(raw); + const validation = validateBaseline({ + version: 'temp', + recordedAt: new Date().toISOString(), + command: 'temp', + metrics: parsed + }); + if (!validation.ok) { + return { ok: false, error: `Invalid metrics: ${validation.errors.join(', ')}` }; + } + return { ok: true, metrics: parsed }; + } catch (error) { + return { ok: false, error: `Failed to parse metrics JSON: ${error.message}` }; + } +} + +module.exports = { + DEFAULT_MIN_DURATION, + BINARY_SEARCH_MIN_DURATION, + normalizeBenchmarkOptions, + runBenchmark, + parseMetrics +}; diff --git a/plugins/drift-detect/lib/perf/breaking-point-finder.js b/plugins/drift-detect/lib/perf/breaking-point-finder.js new file mode 100644 index 00000000..d7239cce --- /dev/null +++ b/plugins/drift-detect/lib/perf/breaking-point-finder.js @@ -0,0 +1,52 @@ +/** + * Binary search helper for breaking point discovery. + * + * @module lib/perf/breaking-point-finder + */ + +/** + * Find breaking point using binary search. + * The runner should return { ok: boolean, data?: any }. + * + * @param {object} options + * @param {number} options.min + * @param {number} options.max + * @param {(value:number)=>Promise<{ok:boolean,data?:any}>} options.runner + * @returns {Promise<{breakingPoint:number|null, attempts:number, history:Array}>} + */ +async function findBreakingPoint({ min, max, runner }) { + if (typeof min !== 'number' || typeof max !== 'number') { + throw new Error('min and max must be numbers'); + } + if (typeof runner !== 'function') { + throw new Error('runner must be a function'); + } + + let low = min; + let high = max; + let breakingPoint = null; + const history = []; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const result = await runner(mid); + history.push({ value: mid, ok: result.ok }); + + if (result.ok) { + low = mid + 1; + } else { + breakingPoint = mid; + high = mid - 1; + } + } + + return { + breakingPoint, + attempts: history.length, + history + }; +} + +module.exports = { + findBreakingPoint +}; diff --git a/plugins/drift-detect/lib/perf/breaking-point-runner.js b/plugins/drift-detect/lib/perf/breaking-point-runner.js new file mode 100644 index 00000000..0f15d5af --- /dev/null +++ b/plugins/drift-detect/lib/perf/breaking-point-runner.js @@ -0,0 +1,60 @@ +/** + * Breaking point runner wrapper for /perf. + * + * @module lib/perf/breaking-point-runner + */ + +const { runBenchmark, parseMetrics, BINARY_SEARCH_MIN_DURATION } = require('./benchmark-runner'); +const { findBreakingPoint } = require('./breaking-point-finder'); + +/** + * Run a binary search to find the breaking point for a numeric parameter. + * The benchmark command should accept the value via an env var. + * + * @param {object} options + * @param {string} options.command + * @param {string} options.paramEnv + * @param {number} options.min + * @param {number} options.max + * @returns {Promise<{breakingPoint:number|null, attempts:number, history:Array}>} + */ +async function runBreakingPointSearch(options) { + const { command, paramEnv, min, max } = options || {}; + + if (!command || typeof command !== 'string') { + throw new Error('command must be a non-empty string'); + } + if (!paramEnv || typeof paramEnv !== 'string') { + throw new Error('paramEnv must be a non-empty string'); + } + if (typeof min !== 'number' || typeof max !== 'number') { + throw new Error('min and max must be numbers'); + } + + const runner = async (value) => { + try { + const result = runBenchmark(command, { + mode: 'binary-search', + duration: BINARY_SEARCH_MIN_DURATION, + env: { + [paramEnv]: String(value) + } + }); + + const parsed = parseMetrics(result.output); + if (!parsed.ok) { + return { ok: false, data: { error: parsed.error } }; + } + + return { ok: true, data: { metrics: parsed.metrics } }; + } catch (error) { + return { ok: false, data: { error: error.message } }; + } + }; + + return findBreakingPoint({ min, max, runner }); +} + +module.exports = { + runBreakingPointSearch +}; diff --git a/plugins/drift-detect/lib/perf/checkpoint.js b/plugins/drift-detect/lib/perf/checkpoint.js new file mode 100644 index 00000000..8926f855 --- /dev/null +++ b/plugins/drift-detect/lib/perf/checkpoint.js @@ -0,0 +1,99 @@ +/** + * Git checkpoint helper for /perf phases. + * + * @module lib/perf/checkpoint + */ + +const { execSync, execFileSync } = require('child_process'); + +/** + * Check if git repo is clean. + * @returns {boolean} + */ +function isWorkingTreeClean() { + const output = execSync('git status --porcelain', { encoding: 'utf8' }).trim(); + return output.length === 0; +} + +/** + * Build checkpoint commit message. + * @param {object} input + * @param {string} input.phase + * @param {string} input.id + * @param {string} [input.baselineVersion] + * @param {string} [input.deltaSummary] + * @returns {string} + */ +function buildCheckpointMessage(input) { + if (!input || typeof input !== 'object') { + throw new Error('Checkpoint input must be an object'); + } + const { phase, id, baselineVersion, deltaSummary } = input; + + if (!phase || typeof phase !== 'string') { + throw new Error('phase is required'); + } + if (!id || typeof id !== 'string') { + throw new Error('id is required'); + } + + const baseline = baselineVersion || 'n/a'; + const delta = deltaSummary || 'n/a'; + return `perf: phase ${phase} [${id}] baseline=${baseline} delta=${delta}`; +} + +/** + * Get the most recent git commit message. + * @returns {string|null} + */ +function getLastCommitMessage() { + try { + return execSync('git log -1 --pretty=%B', { encoding: 'utf8' }).trim(); + } catch { + return null; + } +} + +/** + * Check if the next checkpoint would duplicate the last commit. + * @param {string} message + * @returns {boolean} + */ +function isDuplicateCheckpoint(message) { + const last = getLastCommitMessage(); + if (!last) return false; + return last.trim() === String(message || '').trim(); +} + +/** + * Commit a checkpoint for a perf phase. + * @param {object} input + * @returns {{ ok: boolean, message?: string, reason?: string }} + */ +function commitCheckpoint(input) { + try { + execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' }); + } catch { + return { ok: false, reason: 'not a git repo' }; + } + + if (isWorkingTreeClean()) { + return { ok: false, reason: 'nothing to commit' }; + } + + const message = buildCheckpointMessage(input); + if (isDuplicateCheckpoint(message)) { + return { ok: false, reason: 'duplicate checkpoint' }; + } + execFileSync('git', ['add', '-A'], { stdio: 'ignore' }); + execFileSync('git', ['commit', '-m', message], { stdio: 'ignore' }); + return { ok: true, message }; +} + +module.exports = { + isWorkingTreeClean, + buildCheckpointMessage, + getLastCommitMessage, + isDuplicateCheckpoint, + commitCheckpoint +}; diff --git a/plugins/drift-detect/lib/perf/code-paths.js b/plugins/drift-detect/lib/perf/code-paths.js new file mode 100644 index 00000000..ece2c8bf --- /dev/null +++ b/plugins/drift-detect/lib/perf/code-paths.js @@ -0,0 +1,86 @@ +/** + * Code-path discovery helpers for /perf. + * + * @module lib/perf/code-paths + */ + +const DEFAULT_STOPWORDS = new Set([ + 'the', 'and', 'for', 'with', 'from', 'that', 'this', 'these', 'those', + 'into', 'over', 'under', 'than', 'then', 'when', 'where', 'what', 'which', + 'your', 'you', 'our', 'their', 'there', 'have', 'has', 'had', 'will', + 'would', 'should', 'could', 'about', 'across', 'after', 'before', 'while', + 'perf', 'performance', 'investigation', 'baseline', 'benchmark', 'scenario' +]); + +function normalizeKeywords(text) { + if (!text || typeof text !== 'string') return []; + const tokens = text + .toLowerCase() + .split(/[^a-z0-9]+/g) + .filter(Boolean) + .filter(token => token.length > 2) + .filter(token => !DEFAULT_STOPWORDS.has(token)); + + return Array.from(new Set(tokens)); +} + +function scoreEntry(entry, keywords) { + let score = 0; + if (!entry || keywords.length === 0) return score; + + const haystack = [ + entry.file || '', + ...(entry.symbols || []) + ].join(' ').toLowerCase(); + + for (const keyword of keywords) { + if (haystack.includes(keyword)) score += 1; + } + + return score; +} + +function extractSymbols(fileData) { + if (!fileData || !fileData.symbols) return []; + const symbols = []; + for (const group of Object.values(fileData.symbols)) { + if (!Array.isArray(group)) continue; + for (const symbol of group) { + if (symbol && symbol.name) symbols.push(symbol.name); + } + } + return symbols; +} + +function collectCodePaths(repoMap, scenario, limit = 12) { + if (!repoMap || !repoMap.files) { + return { keywords: normalizeKeywords(scenario), paths: [] }; + } + + const keywords = normalizeKeywords(scenario); + const candidates = []; + + for (const [file, data] of Object.entries(repoMap.files)) { + const symbols = extractSymbols(data); + const entry = { file, symbols }; + const score = scoreEntry(entry, keywords); + if (score <= 0) continue; + candidates.push({ ...entry, score }); + } + + candidates.sort((a, b) => b.score - a.score || a.file.localeCompare(b.file)); + + return { + keywords, + paths: candidates.slice(0, limit).map(item => ({ + file: item.file, + score: item.score, + symbols: item.symbols.slice(0, 8) + })) + }; +} + +module.exports = { + normalizeKeywords, + collectCodePaths +}; diff --git a/plugins/drift-detect/lib/perf/consolidation.js b/plugins/drift-detect/lib/perf/consolidation.js new file mode 100644 index 00000000..f8c292da --- /dev/null +++ b/plugins/drift-detect/lib/perf/consolidation.js @@ -0,0 +1,37 @@ +/** + * Baseline consolidation helper. + * + * @module lib/perf/consolidation + */ + +const baselineStore = require('./baseline-store'); + +/** + * Consolidate a baseline for a version (overwrite existing). + * @param {object} input + * @param {string} input.version + * @param {object} input.baseline + * @param {string} [basePath] + * @returns {{ version: string, path: string }} + */ +function consolidateBaseline(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('consolidateBaseline requires an input object'); + } + const { version, baseline } = input; + + if (!version || typeof version !== 'string') { + throw new Error('version is required'); + } + if (!baseline || typeof baseline !== 'object') { + throw new Error('baseline is required'); + } + + baselineStore.writeBaseline(version, baseline, basePath); + const path = baselineStore.getBaselinePath(version, basePath); + return { version, path }; +} + +module.exports = { + consolidateBaseline +}; diff --git a/plugins/drift-detect/lib/perf/constraint-runner.js b/plugins/drift-detect/lib/perf/constraint-runner.js new file mode 100644 index 00000000..a5c5f6a5 --- /dev/null +++ b/plugins/drift-detect/lib/perf/constraint-runner.js @@ -0,0 +1,69 @@ +/** + * Constraint testing runner for /perf. + * + * @module lib/perf/constraint-runner + */ + +const { runBenchmark, parseMetrics, DEFAULT_MIN_DURATION } = require('./benchmark-runner'); +const { compareBaselines } = require('./baseline-comparator'); + +/** + * Run baseline and constrained benchmarks sequentially. + * Constraints are provided via env vars to keep it cross-platform. + * + * @param {object} options + * @param {string} options.command + * @param {object} options.constraints + * @param {object} [options.env] + * @returns {{ constraints: object, baseline: object, constrained: object, delta: object }} + */ +function runConstraintTest(options) { + const { command, constraints, env } = options || {}; + + if (!command || typeof command !== 'string') { + throw new Error('command must be a non-empty string'); + } + if (!constraints || typeof constraints !== 'object' || Array.isArray(constraints)) { + throw new Error('constraints must be an object'); + } + + const baselineResult = runBenchmark(command, { + duration: DEFAULT_MIN_DURATION, + env: { + ...env + } + }); + const baselineMetrics = parseMetrics(baselineResult.output); + if (!baselineMetrics.ok) { + throw new Error(`Baseline metrics parse failed: ${baselineMetrics.error}`); + } + + const constrainedResult = runBenchmark(command, { + duration: DEFAULT_MIN_DURATION, + env: { + ...env, + PERF_CPU_LIMIT: constraints.cpu, + PERF_MEMORY_LIMIT: constraints.memory + } + }); + const constrainedMetrics = parseMetrics(constrainedResult.output); + if (!constrainedMetrics.ok) { + throw new Error(`Constrained metrics parse failed: ${constrainedMetrics.error}`); + } + + const delta = compareBaselines( + { metrics: baselineMetrics.metrics }, + { metrics: constrainedMetrics.metrics } + ); + + return { + constraints, + baseline: { metrics: baselineMetrics.metrics }, + constrained: { metrics: constrainedMetrics.metrics }, + delta + }; +} + +module.exports = { + runConstraintTest +}; diff --git a/plugins/drift-detect/lib/perf/experiment-runner.js b/plugins/drift-detect/lib/perf/experiment-runner.js new file mode 100644 index 00000000..fbee670d --- /dev/null +++ b/plugins/drift-detect/lib/perf/experiment-runner.js @@ -0,0 +1,32 @@ +/** + * Experiment runner utilities. + * + * @module lib/perf/experiment-runner + */ + +/** + * Run experiments sequentially (never parallel). + * @param {Array} experiments + * @param {(experiment:object)=>Promise} runner + * @returns {Promise<{results:Array}>} + */ +async function runExperiments(experiments, runner) { + if (!Array.isArray(experiments)) { + throw new Error('experiments must be an array'); + } + if (typeof runner !== 'function') { + throw new Error('runner must be a function'); + } + + const results = []; + for (const experiment of experiments) { + const result = await runner(experiment); + results.push(result); + } + + return { results }; +} + +module.exports = { + runExperiments +}; diff --git a/plugins/drift-detect/lib/perf/index.js b/plugins/drift-detect/lib/perf/index.js new file mode 100644 index 00000000..2a8a689a --- /dev/null +++ b/plugins/drift-detect/lib/perf/index.js @@ -0,0 +1,41 @@ +/** + * Performance investigation utilities + * + * @module lib/perf + */ + +const investigationState = require('./investigation-state'); +const baselineStore = require('./baseline-store'); +const baselineComparator = require('./baseline-comparator'); +const benchmarkRunner = require('./benchmark-runner'); +const breakingPointFinder = require('./breaking-point-finder'); +const breakingPointRunner = require('./breaking-point-runner'); +const experimentRunner = require('./experiment-runner'); +const constraintRunner = require('./constraint-runner'); +const checkpoint = require('./checkpoint'); +const profilingRunner = require('./profiling-runner'); +const optimizationRunner = require('./optimization-runner'); +const consolidation = require('./consolidation'); +const profilers = require('./profilers'); +const analyzer = require('./analyzer'); +const argumentParser = require('./argument-parser'); +const codePaths = require('./code-paths'); + +module.exports = { + investigationState, + baselineStore, + baselineComparator, + benchmarkRunner, + breakingPointFinder, + breakingPointRunner, + experimentRunner, + constraintRunner, + checkpoint, + profilingRunner, + optimizationRunner, + consolidation, + profilers, + analyzer, + argumentParser, + codePaths +}; diff --git a/plugins/drift-detect/lib/perf/investigation-state.js b/plugins/drift-detect/lib/perf/investigation-state.js new file mode 100644 index 00000000..3bb091df --- /dev/null +++ b/plugins/drift-detect/lib/perf/investigation-state.js @@ -0,0 +1,788 @@ +/** + * Performance investigation state management + * + * Stores investigation state and logs under the platform-aware state directory: + * - {state-dir}/perf/investigation.json + * - {state-dir}/perf/investigations/{id}.md + * + * @module lib/perf/investigation-state + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { getStateDir } = require('../platform/state-dir'); +const { validateInvestigationState, assertValid } = require('./schemas'); + +const SCHEMA_VERSION = 1; +const INVESTIGATION_FILE = 'investigation.json'; +const LOG_DIR = 'investigations'; +const BASELINE_DIR = 'baselines'; + +const PHASES = [ + 'setup', + 'baseline', + 'breaking-point', + 'constraints', + 'hypotheses', + 'code-paths', + 'profiling', + 'optimization', + 'decision', + 'consolidation' +]; + +/** + * Validate and resolve path to prevent path traversal attacks + * @param {string} basePath - Base directory path + * @returns {string} Validated absolute path + */ +function validatePath(basePath) { + if (typeof basePath !== 'string' || basePath.length === 0) { + throw new Error('Path must be a non-empty string'); + } + const resolved = path.resolve(basePath); + if (resolved.includes('\0')) { + throw new Error('Path contains invalid null byte'); + } + return resolved; +} + +/** + * Validate that target path is within base directory + * @param {string} targetPath - Target file path + * @param {string} basePath - Base directory + */ +function validatePathWithinBase(targetPath, basePath) { + const resolvedTarget = path.resolve(targetPath); + const resolvedBase = path.resolve(basePath); + if (!resolvedTarget.startsWith(resolvedBase + path.sep) && resolvedTarget !== resolvedBase) { + throw new Error('Path traversal detected'); + } +} + +function assertSafeInvestigationId(id) { + if (!id || typeof id !== 'string') { + throw new Error('Investigation id is required'); + } + if (id.includes('..') || id.includes('/') || id.includes('\\') || id.includes('\0')) { + throw new Error('Investigation id contains invalid characters'); + } + if (!/^[a-zA-Z0-9._-]+$/.test(id)) { + throw new Error('Investigation id contains invalid characters'); + } + return id; +} + +/** + * Generate a unique investigation ID + * @returns {string} + */ +function generateInvestigationId() { + const now = new Date(); + const date = now.toISOString().slice(0, 10).replace(/-/g, ''); + const time = now.toISOString().slice(11, 19).replace(/:/g, ''); + const random = crypto.randomBytes(4).toString('hex'); + return `perf-${date}-${time}-${random}`; +} + +/** + * Get perf state directory path + * @param {string} basePath + * @returns {string} + */ +function getPerfDir(basePath = process.cwd()) { + const validatedBase = validatePath(basePath); + const perfDir = path.join(validatedBase, getStateDir(basePath), 'perf'); + validatePathWithinBase(perfDir, validatedBase); + return perfDir; +} + +/** + * Ensure perf directories exist + * @param {string} basePath + * @returns {{ perfDir: string, logDir: string, baselineDir: string }} + */ +function ensurePerfDirs(basePath = process.cwd()) { + const perfDir = getPerfDir(basePath); + const logDir = path.join(perfDir, LOG_DIR); + const baselineDir = path.join(perfDir, BASELINE_DIR); + + if (!fs.existsSync(perfDir)) { + fs.mkdirSync(perfDir, { recursive: true }); + } + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + if (!fs.existsSync(baselineDir)) { + fs.mkdirSync(baselineDir, { recursive: true }); + } + + return { perfDir, logDir, baselineDir }; +} + +/** + * Get path to investigation.json + * @param {string} basePath + * @returns {string} + */ +function getInvestigationPath(basePath = process.cwd()) { + const perfDir = getPerfDir(basePath); + return path.join(perfDir, INVESTIGATION_FILE); +} + +/** + * Get path to investigation log + * @param {string} id + * @param {string} basePath + * @returns {string} + */ +function getInvestigationLogPath(id, basePath = process.cwd()) { + const safeId = assertSafeInvestigationId(id); + const { logDir } = ensurePerfDirs(basePath); + return path.join(logDir, `${safeId}.md`); +} + +/** + * Read investigation.json + * @param {string} basePath + * @returns {object|null} + */ +function readInvestigation(basePath = process.cwd()) { + const investigationPath = getInvestigationPath(basePath); + if (!fs.existsSync(investigationPath)) { + return null; + } + try { + const parsed = JSON.parse(fs.readFileSync(investigationPath, 'utf8')); + const validation = validateInvestigationState(parsed); + if (!validation.ok) { + console.error(`[CRITICAL] Invalid investigation state at ${investigationPath}: ${validation.errors.join(', ')}`); + return null; + } + return parsed; + } catch (error) { + console.error(`[CRITICAL] Corrupted investigation.json at ${investigationPath}: ${error.message}`); + return null; + } +} + +/** + * Write investigation.json + * @param {object} state + * @param {string} basePath + * @returns {boolean} + */ +function writeInvestigation(state, basePath = process.cwd()) { + ensurePerfDirs(basePath); + const investigationPath = getInvestigationPath(basePath); + const nextState = { ...state, updatedAt: new Date().toISOString() }; + assertValid(validateInvestigationState(nextState), 'Invalid investigation state'); + fs.writeFileSync(investigationPath, JSON.stringify(nextState, null, 2), 'utf8'); + return true; +} + +/** + * Update investigation.json with partial updates + * @param {object} updates + * @param {string} basePath + * @returns {object|null} + */ +function updateInvestigation(updates, basePath = process.cwd()) { + const current = readInvestigation(basePath) || {}; + const nextState = { ...current }; + + for (const [key, value] of Object.entries(updates)) { + if (value === null) { + nextState[key] = null; + } else if ( + value && typeof value === 'object' && !Array.isArray(value) && + nextState[key] && typeof nextState[key] === 'object' && !Array.isArray(nextState[key]) + ) { + nextState[key] = { ...nextState[key], ...value }; + } else { + nextState[key] = value; + } + } + + writeInvestigation(nextState, basePath); + return readInvestigation(basePath); +} + +/** + * Initialize a new investigation + * @param {object} options + * @param {string} basePath + * @returns {object} + */ +function initializeInvestigation(options = {}, basePath = process.cwd()) { + const id = options.id || generateInvestigationId(); + const phase = options.phase || PHASES[0]; + + if (!PHASES.includes(phase)) { + throw new Error(`Invalid perf phase: ${phase}`); + } + + const state = { + schemaVersion: SCHEMA_VERSION, + id, + status: 'in_progress', + phase, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + scenario: { + description: options.scenario || '', + metrics: options.metrics || [], + successCriteria: options.successCriteria || '', + scenarios: Array.isArray(options.scenarios) ? options.scenarios : [] + }, + baselines: [], + hypotheses: [], + codePaths: [], + experiments: [], + results: [], + breakingPoint: null, + breakingPointHistory: [], + constraintResults: [], + profilingResults: [], + decision: null + }; + + assertValid(validateInvestigationState(state), 'Invalid initial investigation state'); + writeInvestigation(state, basePath); + return state; +} + +/** + * Append a line to the investigation log + * @param {string} id + * @param {string} content + * @param {string} basePath + */ +function appendInvestigationLog(id, content, basePath = process.cwd()) { + if (!content) return; + const logPath = getInvestigationLogPath(id, basePath); + const entry = content.endsWith('\n') ? content : `${content}\n`; + fs.appendFileSync(logPath, entry, 'utf8'); +} + +/** + * Append a baseline section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.command + * @param {object} input.metrics + * @param {string} input.baselinePath + * @param {string} [input.date] + * @param {string} basePath + */ +function appendBaselineLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendBaselineLog requires an input object'); + } + + const { id, userQuote, command, metrics, baselinePath, date, scenarios } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendBaselineLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendBaselineLog requires a non-empty userQuote'); + } + if (!command || typeof command !== 'string') { + throw new Error('appendBaselineLog requires a non-empty command'); + } + if (!metrics || typeof metrics !== 'object' || Array.isArray(metrics)) { + throw new Error('appendBaselineLog requires a metrics object'); + } + if (!baselinePath || typeof baselinePath !== 'string') { + throw new Error('appendBaselineLog requires a baselinePath'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const metricsText = JSON.stringify(metrics); + const scenarioText = Array.isArray(scenarios) && scenarios.length > 0 + ? scenarios.map((scenario) => scenario.name).filter(Boolean).join(', ') + : ''; + + const entry = [ + `## Baseline - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + scenarioText ? `- Scenarios: ${scenarioText}` : null, + `- Baseline command: \`${command}\``, + `- Metrics: ${metricsText}`, + '', + '**Evidence**', + `- Baseline file: ${baselinePath}`, + '' + ].filter(Boolean).join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a profiling section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.tool + * @param {string} input.command + * @param {string[]} input.artifacts + * @param {string[]} input.hotspots + * @param {string} [input.date] + * @param {string} basePath + */ +function appendProfilingLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendProfilingLog requires an input object'); + } + + const { id, userQuote, tool, command, artifacts, hotspots, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendProfilingLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendProfilingLog requires a non-empty userQuote'); + } + if (!tool || typeof tool !== 'string') { + throw new Error('appendProfilingLog requires a tool'); + } + if (!command || typeof command !== 'string') { + throw new Error('appendProfilingLog requires a command'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const artifactList = Array.isArray(artifacts) ? artifacts : []; + const hotspotList = Array.isArray(hotspots) ? hotspots : []; + + const entry = [ + `## Profiling - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Tool: ${tool}`, + `- Command: \`${command}\``, + '', + '**Evidence**', + artifactList.length ? `- Artifacts: ${artifactList.join(', ')}` : '- Artifacts: n/a', + hotspotList.length ? `- Hotspots: ${hotspotList.join(', ')}` : '- Hotspots: n/a', + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a decision section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.verdict + * @param {string} input.rationale + * @param {string} [input.date] + * @param {string} basePath + */ +function appendDecisionLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendDecisionLog requires an input object'); + } + + const { id, userQuote, verdict, rationale, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendDecisionLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendDecisionLog requires a non-empty userQuote'); + } + if (!verdict || typeof verdict !== 'string') { + throw new Error('appendDecisionLog requires a verdict'); + } + if (!rationale || typeof rationale !== 'string') { + throw new Error('appendDecisionLog requires a rationale'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + + const entry = [ + `## Decision - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Verdict: ${verdict}`, + `- Rationale: ${rationale}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a setup section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.scenario + * @param {string} input.command + * @param {string} input.version + * @param {string} [input.date] + * @param {string} basePath + */ +function appendSetupLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendSetupLog requires an input object'); + } + + const { id, userQuote, scenario, command, version, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendSetupLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendSetupLog requires a non-empty userQuote'); + } + if (!scenario || typeof scenario !== 'string') { + throw new Error('appendSetupLog requires a scenario'); + } + if (!command || typeof command !== 'string') { + throw new Error('appendSetupLog requires a command'); + } + if (!version || typeof version !== 'string') { + throw new Error('appendSetupLog requires a version'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Setup - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Scenario: ${scenario}`, + `- Command: \`${command}\``, + `- Version: ${version}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a breaking point section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.paramEnv + * @param {number} input.min + * @param {number} input.max + * @param {number|null} input.breakingPoint + * @param {string} [input.date] + * @param {string} basePath + */ +function appendBreakingPointLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendBreakingPointLog requires an input object'); + } + const { id, userQuote, paramEnv, min, max, breakingPoint, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendBreakingPointLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendBreakingPointLog requires a non-empty userQuote'); + } + if (!paramEnv || typeof paramEnv !== 'string') { + throw new Error('appendBreakingPointLog requires a paramEnv'); + } + if (typeof min !== 'number' || typeof max !== 'number') { + throw new Error('appendBreakingPointLog requires numeric min/max'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Breaking Point - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Param env: ${paramEnv}`, + `- Range: ${min}..${max}`, + `- Breaking point: ${breakingPoint ?? 'n/a'}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a constraints section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {object} input.constraints + * @param {object} input.delta + * @param {string} [input.date] + * @param {string} basePath + */ +function appendConstraintLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendConstraintLog requires an input object'); + } + const { id, userQuote, constraints, delta, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendConstraintLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendConstraintLog requires a non-empty userQuote'); + } + if (!constraints || typeof constraints !== 'object') { + throw new Error('appendConstraintLog requires constraints'); + } + if (!delta || typeof delta !== 'object') { + throw new Error('appendConstraintLog requires delta'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Constraints - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- CPU: ${constraints.cpu || 'n/a'}`, + `- Memory: ${constraints.memory || 'n/a'}`, + '', + '**Evidence**', + `- Delta: ${JSON.stringify(delta.metrics || {})}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a hypotheses section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {Array} input.hypotheses + * @param {string} [input.date] + * @param {string} basePath + */ +function appendHypothesesLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendHypothesesLog requires an input object'); + } + const { id, userQuote, hypotheses, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendHypothesesLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendHypothesesLog requires a non-empty userQuote'); + } + if (!Array.isArray(hypotheses)) { + throw new Error('appendHypothesesLog requires hypotheses array'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const lines = hypotheses.map((item) => { + if (!item) return null; + const label = item.id ? `${item.id}: ` : ''; + const evidence = item.evidence ? ` (evidence: ${item.evidence})` : ''; + const confidence = item.confidence ? ` [${item.confidence}]` : ''; + return `- ${label}${item.hypothesis || 'n/a'}${confidence}${evidence}`; + }).filter(Boolean); + + const entry = [ + `## Hypotheses - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + lines.length > 0 ? lines.join('\n') : '- n/a', + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a code-paths section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string[]} input.keywords + * @param {Array} input.paths + * @param {string} [input.date] + * @param {string} basePath + */ +function appendCodePathsLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendCodePathsLog requires an input object'); + } + const { id, userQuote, keywords, paths, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendCodePathsLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendCodePathsLog requires a non-empty userQuote'); + } + if (!Array.isArray(paths)) { + throw new Error('appendCodePathsLog requires paths array'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const keywordText = Array.isArray(keywords) && keywords.length > 0 ? keywords.join(', ') : 'n/a'; + const pathLines = paths.map((pathEntry) => { + const file = pathEntry.file || 'n/a'; + const score = typeof pathEntry.score === 'number' ? ` (score: ${pathEntry.score})` : ''; + const symbols = Array.isArray(pathEntry.symbols) && pathEntry.symbols.length > 0 + ? ` [${pathEntry.symbols.join(', ')}]` + : ''; + return `- ${file}${score}${symbols}`; + }); + + const entry = [ + `## Code Paths - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Keywords: ${keywordText}`, + pathLines.length > 0 ? pathLines.join('\n') : '- n/a', + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append an optimization section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.change + * @param {object} input.delta + * @param {string} input.verdict + * @param {string} [input.date] + * @param {string} basePath + */ +function appendOptimizationLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendOptimizationLog requires an input object'); + } + const { id, userQuote, change, delta, verdict, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendOptimizationLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendOptimizationLog requires a non-empty userQuote'); + } + if (!change || typeof change !== 'string') { + throw new Error('appendOptimizationLog requires a change summary'); + } + if (!delta || typeof delta !== 'object') { + throw new Error('appendOptimizationLog requires delta'); + } + if (!verdict || typeof verdict !== 'string') { + throw new Error('appendOptimizationLog requires a verdict'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Optimization - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Change: ${change}`, + `- Verdict: ${verdict}`, + '', + '**Evidence**', + `- Delta: ${JSON.stringify(delta.metrics || {})}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a consolidation section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.version + * @param {string} input.path + * @param {string} [input.date] + * @param {string} basePath + */ +function appendConsolidationLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendConsolidationLog requires an input object'); + } + + const { id, userQuote, version, path, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendConsolidationLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendConsolidationLog requires a non-empty userQuote'); + } + if (!version || typeof version !== 'string') { + throw new Error('appendConsolidationLog requires a version'); + } + if (!path || typeof path !== 'string') { + throw new Error('appendConsolidationLog requires a path'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Consolidation - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Version: ${version}`, + `- Baseline file: ${path}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +module.exports = { + SCHEMA_VERSION, + PHASES, + generateInvestigationId, + getPerfDir, + ensurePerfDirs, + getInvestigationPath, + getInvestigationLogPath, + readInvestigation, + writeInvestigation, + updateInvestigation, + initializeInvestigation, + appendInvestigationLog, + appendBaselineLog, + appendProfilingLog, + appendDecisionLog, + appendSetupLog, + appendBreakingPointLog, + appendConstraintLog, + appendHypothesesLog, + appendCodePathsLog, + appendOptimizationLog, + appendConsolidationLog +}; diff --git a/plugins/drift-detect/lib/perf/optimization-runner.js b/plugins/drift-detect/lib/perf/optimization-runner.js new file mode 100644 index 00000000..4fb6ca0d --- /dev/null +++ b/plugins/drift-detect/lib/perf/optimization-runner.js @@ -0,0 +1,67 @@ +/** + * Optimization runner for /perf experiments. + * + * @module lib/perf/optimization-runner + */ + +const { runBenchmark, parseMetrics, DEFAULT_MIN_DURATION } = require('./benchmark-runner'); +const { compareBaselines } = require('./baseline-comparator'); +const { isWorkingTreeClean } = require('./checkpoint'); + +/** + * Run a single optimization experiment with two benchmark runs. + * NOTE: This helper does not modify code; it assumes the change was applied externally. + * + * @param {object} options + * @param {string} options.command + * @param {string} options.changeSummary + * @param {object} [options.env] + * @returns {{ baseline: object, experiment: object, delta: object, verdict: string, change: string }} + */ +function runOptimizationExperiment(options) { + const { command, changeSummary, env } = options || {}; + + if (!command || typeof command !== 'string') { + throw new Error('command must be a non-empty string'); + } + if (!changeSummary || typeof changeSummary !== 'string') { + throw new Error('changeSummary must be a non-empty string'); + } + + const shouldCheckClean = options?.requireClean !== false; + if (shouldCheckClean && !isWorkingTreeClean()) { + throw new Error('working tree is dirty before experiment'); + } + + const baselineRun = runBenchmark(command, { duration: DEFAULT_MIN_DURATION, env }); + const baselineMetrics = parseMetrics(baselineRun.output); + if (!baselineMetrics.ok) { + throw new Error(`Baseline parse failed: ${baselineMetrics.error}`); + } + + // NOTE: Caller is responsible for applying the experiment change here. + // Warm up the system (caches/JIT) before capturing experiment metrics. + runBenchmark(command, { duration: DEFAULT_MIN_DURATION, env }); + const experimentRun = runBenchmark(command, { duration: DEFAULT_MIN_DURATION, env }); + const experimentMetrics = parseMetrics(experimentRun.output); + if (!experimentMetrics.ok) { + throw new Error(`Experiment parse failed: ${experimentMetrics.error}`); + } + + const delta = compareBaselines( + { metrics: baselineMetrics.metrics }, + { metrics: experimentMetrics.metrics } + ); + + return { + change: changeSummary, + baseline: { metrics: baselineMetrics.metrics }, + experiment: { metrics: experimentMetrics.metrics }, + delta, + verdict: 'inconclusive' + }; +} + +module.exports = { + runOptimizationExperiment +}; diff --git a/plugins/drift-detect/lib/perf/profilers/go.js b/plugins/drift-detect/lib/perf/profilers/go.js new file mode 100644 index 00000000..9616ae6e --- /dev/null +++ b/plugins/drift-detect/lib/perf/profilers/go.js @@ -0,0 +1,22 @@ +/** + * Go pprof helper. + * + * @module lib/perf/profilers/go + */ + +module.exports = { + id: 'pprof', + tool: 'pprof', + buildCommand(options = {}) { + const command = options.command || 'go test'; + const output = options.output || 'cpu.pprof'; + return `${command} -cpuprofile=${output}`; + }, + parseOutput() { + return { + tool: 'pprof', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/drift-detect/lib/perf/profilers/index.js b/plugins/drift-detect/lib/perf/profilers/index.js new file mode 100644 index 00000000..20e66af9 --- /dev/null +++ b/plugins/drift-detect/lib/perf/profilers/index.js @@ -0,0 +1,46 @@ +/** + * Profilers registry for /perf. + * + * @module lib/perf/profilers + */ + +const fs = require('fs'); +const path = require('path'); +const cliEnhancers = require('../../patterns/cli-enhancers'); +const nodeProfiler = require('./node'); +const pythonProfiler = require('./python'); +const goProfiler = require('./go'); +const rustProfiler = require('./rust'); +const javaProfiler = require('./java'); + +function hasJavaIndicators(repoPath) { + const indicators = ['pom.xml', 'build.gradle', 'build.gradle.kts']; + return indicators.some((file) => fs.existsSync(path.join(repoPath, file))); +} + +function selectProfiler(repoPath = process.cwd()) { + const languages = cliEnhancers.detectProjectLanguages(repoPath); + + if (hasJavaIndicators(repoPath)) return javaProfiler; + if (languages.includes('typescript') || languages.includes('javascript')) return nodeProfiler; + if (languages.includes('go')) return goProfiler; + if (languages.includes('python')) return pythonProfiler; + if (languages.includes('rust')) return rustProfiler; + + return nodeProfiler; +} + +function listAvailable() { + return [ + nodeProfiler.id, + javaProfiler.id, + pythonProfiler.id, + goProfiler.id, + rustProfiler.id + ]; +} + +module.exports = { + listAvailable, + selectProfiler +}; diff --git a/plugins/drift-detect/lib/perf/profilers/java.js b/plugins/drift-detect/lib/perf/profilers/java.js new file mode 100644 index 00000000..bb464130 --- /dev/null +++ b/plugins/drift-detect/lib/perf/profilers/java.js @@ -0,0 +1,23 @@ +/** + * Java JFR profiler helper. + * + * @module lib/perf/profilers/java + */ + +module.exports = { + id: 'jfr', + tool: 'jfr', + buildCommand(options = {}) { + const command = options.command || 'java'; + const output = options.output || 'profile.jfr'; + const duration = options.duration || '60s'; + return `${command} -XX:StartFlightRecording=duration=${duration},filename=${output}`; + }, + parseOutput() { + return { + tool: 'jfr', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/drift-detect/lib/perf/profilers/node.js b/plugins/drift-detect/lib/perf/profilers/node.js new file mode 100644 index 00000000..95b7a857 --- /dev/null +++ b/plugins/drift-detect/lib/perf/profilers/node.js @@ -0,0 +1,22 @@ +/** + * Node.js profiler helper. + * + * @module lib/perf/profilers/node + */ + +module.exports = { + id: 'node', + tool: '--cpu-prof', + buildCommand(options = {}) { + const command = options.command || 'node'; + const output = options.output || 'node.cpuprofile'; + return `${command} --cpu-prof --cpu-prof-name=${output}`; + }, + parseOutput() { + return { + tool: 'node', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/drift-detect/lib/perf/profilers/python.js b/plugins/drift-detect/lib/perf/profilers/python.js new file mode 100644 index 00000000..98ede075 --- /dev/null +++ b/plugins/drift-detect/lib/perf/profilers/python.js @@ -0,0 +1,23 @@ +/** + * Python cProfile helper. + * + * @module lib/perf/profilers/python + */ + +module.exports = { + id: 'cprofile', + tool: 'cProfile', + buildCommand(options = {}) { + const command = options.command || 'python'; + const target = options.target || '-m'; + const output = options.output || 'profile.prof'; + return `${command} -m cProfile -o ${output} ${target}`; + }, + parseOutput() { + return { + tool: 'cprofile', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/drift-detect/lib/perf/profilers/rust.js b/plugins/drift-detect/lib/perf/profilers/rust.js new file mode 100644 index 00000000..416186d6 --- /dev/null +++ b/plugins/drift-detect/lib/perf/profilers/rust.js @@ -0,0 +1,23 @@ +/** + * Rust perf helper (Linux). + * + * @module lib/perf/profilers/rust + */ + +module.exports = { + id: 'perf', + tool: 'perf', + buildCommand(options = {}) { + const command = options.command || 'perf record'; + const output = options.output || 'perf.data'; + const target = options.target || './target/release/app'; + return `${command} -o ${output} ${target}`; + }, + parseOutput() { + return { + tool: 'perf', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/drift-detect/lib/perf/profiling-runner.js b/plugins/drift-detect/lib/perf/profiling-runner.js new file mode 100644 index 00000000..e1204f25 --- /dev/null +++ b/plugins/drift-detect/lib/perf/profiling-runner.js @@ -0,0 +1,48 @@ +/** + * Profiling execution helper. + * + * @module lib/perf/profiling-runner + */ + +const { execSync } = require('child_process'); +const profilers = require('./profilers'); + +/** + * Run a profiling command and return artifacts/hotspots metadata. + * @param {object} options + * @param {string} [options.repoPath] + * @param {object} [options.profileOptions] + * @returns {{ ok: boolean, result?: object, error?: string }} + */ +function runProfiling(options = {}) { + const repoPath = options.repoPath || process.cwd(); + const profiler = profilers.selectProfiler(repoPath); + + if (!profiler || typeof profiler.buildCommand !== 'function') { + return { ok: false, error: 'No profiler available' }; + } + + const command = profiler.buildCommand(options.profileOptions || {}); + try { + execSync(command, { stdio: 'pipe' }); + } catch (error) { + return { ok: false, error: error.message }; + } + + const parsed = typeof profiler.parseOutput === 'function' + ? profiler.parseOutput() + : { tool: profiler.id, hotspots: [], artifacts: [] }; + + const result = { + tool: profiler.id, + command, + hotspots: parsed.hotspots || [], + artifacts: parsed.artifacts || [] + }; + + return { ok: true, result }; +} + +module.exports = { + runProfiling +}; diff --git a/plugins/drift-detect/lib/perf/schemas.js b/plugins/drift-detect/lib/perf/schemas.js new file mode 100644 index 00000000..8b86761e --- /dev/null +++ b/plugins/drift-detect/lib/perf/schemas.js @@ -0,0 +1,140 @@ +/** + * Schema validation helpers for /perf. + * + * @module lib/perf/schemas + */ + +const REQUIRED_INVESTIGATION_FIELDS = ['schemaVersion', 'id', 'status', 'phase', 'scenario']; +const REQUIRED_BASELINE_FIELDS = ['version', 'recordedAt', 'metrics', 'command']; + +function isObject(value) { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function validateInvestigationState(state) { + const errors = []; + + if (!isObject(state)) { + return { ok: false, errors: ['state must be an object'] }; + } + + for (const field of REQUIRED_INVESTIGATION_FIELDS) { + if (!Object.prototype.hasOwnProperty.call(state, field)) { + errors.push(`missing ${field}`); + } + } + + if (typeof state.id !== 'string' || state.id.trim().length === 0) { + errors.push('id must be a non-empty string'); + } + + if (typeof state.phase !== 'string' || state.phase.trim().length === 0) { + errors.push('phase must be a non-empty string'); + } + + if (!isObject(state.scenario)) { + errors.push('scenario must be an object'); + } else { + if (typeof state.scenario.description !== 'string') { + errors.push('scenario.description must be a string'); + } + if (!Array.isArray(state.scenario.metrics)) { + errors.push('scenario.metrics must be an array'); + } + if (typeof state.scenario.successCriteria !== 'string') { + errors.push('scenario.successCriteria must be a string'); + } + if (state.scenario.scenarios != null) { + if (!Array.isArray(state.scenario.scenarios)) { + errors.push('scenario.scenarios must be an array when provided'); + } else { + state.scenario.scenarios.forEach((scenario, index) => { + if (!isObject(scenario)) { + errors.push(`scenario.scenarios[${index}] must be an object`); + return; + } + if (typeof scenario.name !== 'string' || scenario.name.trim().length === 0) { + errors.push(`scenario.scenarios[${index}].name must be a non-empty string`); + } + if (scenario.params != null && !isObject(scenario.params)) { + errors.push(`scenario.scenarios[${index}].params must be an object when provided`); + } + }); + } + } + } + + return { ok: errors.length === 0, errors }; +} + +function validateBaseline(baseline) { + const errors = []; + + if (!isObject(baseline)) { + return { ok: false, errors: ['baseline must be an object'] }; + } + + for (const field of REQUIRED_BASELINE_FIELDS) { + if (!Object.prototype.hasOwnProperty.call(baseline, field)) { + errors.push(`missing ${field}`); + } + } + + if (typeof baseline.version !== 'string' || baseline.version.trim().length === 0) { + errors.push('version must be a non-empty string'); + } + + if (typeof baseline.recordedAt !== 'string' || baseline.recordedAt.trim().length === 0) { + errors.push('recordedAt must be an ISO8601 string'); + } + + if (typeof baseline.command !== 'string' || baseline.command.trim().length === 0) { + errors.push('command must be a non-empty string'); + } + + if (!isObject(baseline.metrics)) { + errors.push('metrics must be an object'); + } else { + if (baseline.metrics.scenarios != null) { + if (!isObject(baseline.metrics.scenarios)) { + errors.push('metrics.scenarios must be an object when provided'); + } else { + for (const [scenarioName, scenarioMetrics] of Object.entries(baseline.metrics.scenarios)) { + if (!isObject(scenarioMetrics)) { + errors.push(`metrics.scenarios.${scenarioName} must be an object`); + continue; + } + for (const [key, value] of Object.entries(scenarioMetrics)) { + if (typeof value !== 'number' || Number.isNaN(value)) { + errors.push(`metric ${scenarioName}.${key} must be a number`); + } + } + } + } + } else { + for (const [key, value] of Object.entries(baseline.metrics)) { + if (typeof value !== 'number' || Number.isNaN(value)) { + errors.push(`metric ${key} must be a number`); + } + } + } + } + + if (baseline.env && !isObject(baseline.env)) { + errors.push('env must be an object when provided'); + } + + return { ok: errors.length === 0, errors }; +} + +function assertValid(result, message) { + if (!result.ok) { + throw new Error(`${message}: ${result.errors.join(', ')}`); + } +} + +module.exports = { + validateInvestigationState, + validateBaseline, + assertValid +}; diff --git a/plugins/enhance/README.md b/plugins/enhance/README.md index 80636ddd..b8ab408a 100644 --- a/plugins/enhance/README.md +++ b/plugins/enhance/README.md @@ -1,6 +1,6 @@ # enhance -Master enhancement orchestrator for plugins, agents, prompts, and documentation. +Master enhancement orchestrator for plugins, agents, prompts, docs, hooks, and skills. ## Overview @@ -15,7 +15,9 @@ The enhance plugin provides specialized analyzers for different content types, i ├─→ /enhance:prompt → General prompt patterns (clarity, structure, examples) ├─→ /enhance:docs → Documentation analysis (RAG optimization, readability) ├─→ /enhance:plugin → Plugin structure (MCP tools, security patterns) - └─→ /enhance:claudemd → Project memory optimization (CLAUDE.md/AGENTS.md) + ├─→ /enhance:claudemd → Project memory optimization (CLAUDE.md/AGENTS.md) + ├─→ /enhance:hooks → Hook definitions (frontmatter, safety) + └─→ /enhance:skills → SKILL.md structure and triggers ``` **Analysis depth**: Certainty-based findings (HIGH, MEDIUM, LOW) @@ -93,6 +95,28 @@ Analyze project memory files (CLAUDE.md, AGENTS.md). **Detects**: Missing sections, broken references, README duplication, cross-platform issues +### `/enhance:hooks` + +Analyze hook definitions for frontmatter quality. + +``` +/enhance:hooks # All hook definitions +/enhance:hooks pre-commit.md # Specific hook +``` + +**Detects**: Missing frontmatter, missing name/description + +### `/enhance:skills` + +Analyze SKILL.md files for required metadata and trigger clarity. + +``` +/enhance:skills # All SKILL.md files +/enhance:skills enhance-docs # Specific skill +``` + +**Detects**: Missing frontmatter, missing name/description, missing trigger phrase + ## Agents | Agent | Purpose | Model | @@ -102,6 +126,8 @@ Analyze project memory files (CLAUDE.md, AGENTS.md). | `docs-enhancer` | RAG optimization, readability, token efficiency | opus | | `plugin-enhancer` | MCP schemas, security patterns, structure | sonnet | | `claudemd-enhancer` | Project memory validation, cross-platform | opus | +| `hooks-enhancer` | Hook frontmatter, structure, safety | sonnet | +| `skills-enhancer` | SKILL.md structure, trigger phrases | sonnet | ## Certainty Levels diff --git a/plugins/enhance/agents/agent-enhancer.md b/plugins/enhance/agents/agent-enhancer.md index 58365d65..a32252ff 100644 --- a/plugins/enhance/agents/agent-enhancer.md +++ b/plugins/enhance/agents/agent-enhancer.md @@ -9,6 +9,8 @@ model: opus You analyze agent prompt files for prompt engineering best practices, identifying structural issues, tool configuration problems, and optimization opportunities. +You MUST execute the enhance-agent-prompts skill to produce the output. Do not bypass the skill. + ## Your Role You are a prompt optimization analyzer that: diff --git a/plugins/enhance/agents/claudemd-enhancer.md b/plugins/enhance/agents/claudemd-enhancer.md index 040e48ec..a25183c1 100644 --- a/plugins/enhance/agents/claudemd-enhancer.md +++ b/plugins/enhance/agents/claudemd-enhancer.md @@ -9,6 +9,8 @@ model: opus You analyze project memory files (CLAUDE.md, AGENTS.md) to optimize them for AI assistant understanding and efficiency. +You MUST execute the enhance-claude-memory skill to produce the output. Do not bypass the skill. + ## Your Role You are a project memory optimization analyzer that: diff --git a/plugins/enhance/agents/docs-enhancer.md b/plugins/enhance/agents/docs-enhancer.md index f109f31a..7a78209a 100644 --- a/plugins/enhance/agents/docs-enhancer.md +++ b/plugins/enhance/agents/docs-enhancer.md @@ -9,6 +9,8 @@ model: opus You analyze documentation files for both human readability and AI/RAG optimization, identifying structural issues, inefficiencies, and opportunities for improvement. +You MUST execute the enhance-docs skill to produce the output. Do not bypass the skill. + ## Your Role You are a documentation optimization analyzer that: diff --git a/plugins/enhance/agents/enhancement-orchestrator.md b/plugins/enhance/agents/enhancement-orchestrator.md index d0280964..ab74db0f 100644 --- a/plugins/enhance/agents/enhancement-orchestrator.md +++ b/plugins/enhance/agents/enhancement-orchestrator.md @@ -9,6 +9,8 @@ model: opus You coordinate all enhancement analyzers in parallel, aggregate their findings, and generate a unified report through the enhancement-reporter. +You MUST execute the enhance-orchestrator skill to produce the output. Do not bypass the skill. + ## Your Role You are the master orchestrator that: @@ -35,7 +37,7 @@ const focusType = args.find(a => a.startsWith('--focus='))?.split('=')[1]; const verbose = args.includes('--verbose'); // --- Input Validation --- -const VALID_FOCUS_TYPES = ['plugin', 'agent', 'claudemd', 'docs', 'prompt']; +const VALID_FOCUS_TYPES = ['plugin', 'agent', 'claudemd', 'claude-memory', 'docs', 'prompt', 'hooks', 'skills']; const VALID_FLAGS = ['--apply', '--verbose', '--focus=']; // Validate focus type if provided @@ -53,7 +55,7 @@ if (unknownFlags.length > 0) { **Supported flags:** - `--apply` - Apply auto-fixes for HIGH certainty issues after report -- `--focus=TYPE` - Run only specified enhancer(s): plugin, agent, claudemd, docs, prompt +- `--focus=TYPE` - Run only specified enhancer(s): plugin, agent, claudemd/claude-memory, docs, prompt, hooks, skills - `--verbose` - Include LOW certainty issues in report ## Enhancer Registry @@ -66,6 +68,8 @@ if (unknownFlags.length > 0) { | claudemd | enhance:claudemd-enhancer | CLAUDE.md/AGENTS.md files | sonnet | | docs | enhance:docs-enhancer | Documentation files | sonnet | | prompt | enhance:prompt-enhancer | General prompt files | opus | +| hooks | enhance:hooks-enhancer | Hook definitions and frontmatter | sonnet | +| skills | enhance:skills-enhancer | SKILL.md structure and triggers | sonnet | ## Workflow @@ -83,14 +87,19 @@ const hasClaudeMd = await Glob({ pattern: '**/CLAUDE.md', path: targetPath }) || const hasDocs = await Glob({ pattern: 'docs/**/*.md', path: targetPath }); const hasPrompts = await Glob({ pattern: '**/prompts/**/*.md', path: targetPath }) || await Glob({ pattern: '**/commands/**/*.md', path: targetPath }); +const hasHooks = await Glob({ pattern: '**/hooks/**/*.md', path: targetPath }); +const hasSkills = await Glob({ pattern: '**/skills/**/SKILL.md', path: targetPath }); // Build enhancer list const enhancersToRun = []; -if (!focusType || focusType === 'plugin') enhancersToRun.push('plugin'); -if (!focusType || focusType === 'agent') enhancersToRun.push('agent'); -if (!focusType || focusType === 'claudemd') enhancersToRun.push('claudemd'); -if (!focusType || focusType === 'docs') enhancersToRun.push('docs'); -if (!focusType || focusType === 'prompt') enhancersToRun.push('prompt'); +const focus = focusType === 'claude-memory' ? 'claudemd' : focusType; +if (!focus || focus === 'plugin') enhancersToRun.push('plugin'); +if (!focus || focus === 'agent') enhancersToRun.push('agent'); +if (!focus || focus === 'claudemd') enhancersToRun.push('claudemd'); +if (!focus || focus === 'docs') enhancersToRun.push('docs'); +if (!focus || focus === 'prompt') enhancersToRun.push('prompt'); +if (!focus || focus === 'hooks') enhancersToRun.push('hooks'); +if (!focus || focus === 'skills') enhancersToRun.push('skills'); ``` ### Phase 2: Launch Enhancers in Parallel @@ -99,12 +108,21 @@ Launch all applicable enhancers simultaneously using Task(): ```javascript const enhancerPromises = []; +const enhancerAgents = { + plugin: 'enhance:plugin-enhancer', + agent: 'enhance:agent-enhancer', + claudemd: 'enhance:claudemd-enhancer', + docs: 'enhance:docs-enhancer', + prompt: 'enhance:prompt-enhancer', + hooks: 'enhance:hooks-enhancer', + skills: 'enhance:skills-enhancer' +}; // Plugin Enhancer if (enhancersToRun.includes('plugin') && hasPlugins.length > 0) { enhancerPromises.push( Task({ - subagent_type: "enhance:plugin-enhancer", + subagent_type: enhancerAgents.plugin, prompt: `Analyze plugins in ${targetPath}. Options: @@ -134,7 +152,7 @@ Return findings as JSON: if (enhancersToRun.includes('agent') && hasAgents.length > 0) { enhancerPromises.push( Task({ - subagent_type: "enhance:agent-enhancer", + subagent_type: enhancerAgents.agent, prompt: `Analyze agent prompts in ${targetPath}. Options: @@ -149,7 +167,7 @@ Return findings as JSON with same structure.` if (enhancersToRun.includes('claudemd') && hasClaudeMd.length > 0) { enhancerPromises.push( Task({ - subagent_type: "enhance:claudemd-enhancer", + subagent_type: enhancerAgents.claudemd, prompt: `Analyze project memory files (CLAUDE.md/AGENTS.md) in ${targetPath}. Options: @@ -164,7 +182,7 @@ Return findings as JSON with same structure.` if (enhancersToRun.includes('docs') && hasDocs.length > 0) { enhancerPromises.push( Task({ - subagent_type: "enhance:docs-enhancer", + subagent_type: enhancerAgents.docs, prompt: `Analyze documentation in ${targetPath}. Options: @@ -180,7 +198,7 @@ Return findings as JSON with same structure.` if (enhancersToRun.includes('prompt') && hasPrompts.length > 0) { enhancerPromises.push( Task({ - subagent_type: "enhance:prompt-enhancer", + subagent_type: enhancerAgents.prompt, prompt: `Analyze prompt files in ${targetPath}. Options: @@ -275,7 +293,7 @@ if (applyFixes) { for (const [enhancerType, fixes] of Object.entries(byEnhancer)) { await Task({ - subagent_type: `enhance:${enhancerType}-enhancer`, + subagent_type: enhancerAgents[enhancerType], prompt: `Apply these HIGH certainty fixes: ${JSON.stringify(fixes, null, 2)} @@ -416,3 +434,31 @@ This agent is invoked by: - `/enhance` master command (primary entry point) - Manual orchestration for comprehensive analysis - CI pipelines for quality gates +if (enhancersToRun.includes('hooks') && hasHooks.length > 0) { + enhancerPromises.push( + Task({ + subagent_type: enhancerAgents.hooks, + prompt: `Analyze hook definitions in ${targetPath}. + +Options: +- verbose: ${verbose} + +Return findings as JSON with same structure.` + }) + ); +} + +// Skills Enhancer +if (enhancersToRun.includes('skills') && hasSkills.length > 0) { + enhancerPromises.push( + Task({ + subagent_type: enhancerAgents.skills, + prompt: `Analyze SKILL.md files in ${targetPath}. + +Options: +- verbose: ${verbose} + +Return findings as JSON with same structure.` + }) + ); +} diff --git a/plugins/enhance/agents/enhancement-reporter.md b/plugins/enhance/agents/enhancement-reporter.md index 95dffc3a..b9056732 100644 --- a/plugins/enhance/agents/enhancement-reporter.md +++ b/plugins/enhance/agents/enhancement-reporter.md @@ -9,6 +9,8 @@ model: sonnet You synthesize findings from multiple enhancers into a unified, deduplicated report sorted by certainty and actionability. +You MUST execute the enhance-reporter skill to produce the output. Do not bypass the skill. + ## Your Role You are a report synthesizer that: @@ -307,7 +309,7 @@ No issues found across {n} enhancers. ```markdown ## Status: Clean -No issues found across 5 enhancers. +No issues found across 7 enhancers. ``` diff --git a/plugins/enhance/agents/hooks-enhancer.md b/plugins/enhance/agents/hooks-enhancer.md new file mode 100644 index 00000000..5384889e --- /dev/null +++ b/plugins/enhance/agents/hooks-enhancer.md @@ -0,0 +1,12 @@ +--- +name: hooks-enhancer +description: Analyze hook definitions for safety and best practices +tools: Read, Glob, Grep +model: opus +--- + +# Hooks Enhancer Agent + +You analyze hook definitions for safety, correctness, and best practices. + +You MUST execute the enhance-hooks skill to produce the output. Do not bypass the skill. diff --git a/plugins/enhance/agents/plugin-enhancer.md b/plugins/enhance/agents/plugin-enhancer.md index 30bb7851..2537d417 100644 --- a/plugins/enhance/agents/plugin-enhancer.md +++ b/plugins/enhance/agents/plugin-enhancer.md @@ -9,6 +9,8 @@ model: sonnet You analyze Claude Code plugins for structure issues, MCP tool definition problems, and security vulnerabilities. +You MUST execute the enhance-plugins skill to produce the output. Do not bypass the skill. + ## Your Role You are a plugin quality analyzer that: diff --git a/plugins/enhance/agents/prompt-enhancer.md b/plugins/enhance/agents/prompt-enhancer.md index b89febbe..964e1b52 100644 --- a/plugins/enhance/agents/prompt-enhancer.md +++ b/plugins/enhance/agents/prompt-enhancer.md @@ -9,6 +9,8 @@ model: opus You analyze prompt files for prompt engineering best practices, identifying clarity issues, structural problems, and optimization opportunities. +You MUST execute the enhance-prompts skill to produce the output. Do not bypass the skill. + ## Your Role You are a prompt engineering optimizer that: diff --git a/plugins/enhance/agents/skills-enhancer.md b/plugins/enhance/agents/skills-enhancer.md new file mode 100644 index 00000000..909cb3f6 --- /dev/null +++ b/plugins/enhance/agents/skills-enhancer.md @@ -0,0 +1,12 @@ +--- +name: skills-enhancer +description: Analyze SKILL.md files for trigger and structure quality +tools: Read, Glob, Grep +model: opus +--- + +# Skills Enhancer Agent + +You analyze skill definitions for trigger quality and structural best practices. + +You MUST execute the enhance-skills skill to produce the output. Do not bypass the skill. diff --git a/plugins/enhance/commands/enhance.md b/plugins/enhance/commands/enhance.md index d7343b62..d0aa72b6 100644 --- a/plugins/enhance/commands/enhance.md +++ b/plugins/enhance/commands/enhance.md @@ -1,5 +1,5 @@ --- -description: Analyze plugin structures, MCP tools, agent prompts, general prompts, documentation, and security patterns +description: Analyze plugins, agents, prompts, docs, hooks, and skills for best-practice gaps argument-hint: "[target-path] [--apply] [--focus=TYPE] [--verbose]" --- @@ -9,19 +9,21 @@ Run all enhancement analyzers in parallel and generate a unified report. ## Overview -The master `/enhance` command orchestrates all 5 specialized enhancers: +The master `/enhance` command orchestrates 7 specialized enhancers: - **plugin** - Plugin structures, MCP tools, security patterns - **agent** - Agent prompts, frontmatter, tool restrictions - **claudemd** - CLAUDE.md/AGENTS.md project memory files - **docs** - Documentation structure and RAG optimization - **prompt** - General prompt quality and clarity +- **hooks** - Hook definitions and frontmatter quality +- **skills** - SKILL.md structure and trigger clarity ## Arguments Parse from $ARGUMENTS: - **target-path**: Directory or file to analyze (default: current directory) - **--apply**: Apply auto-fixes for HIGH certainty issues after report -- **--focus=TYPE**: Run only specified enhancer(s): plugin, agent, claudemd, docs, prompt +- **--focus=TYPE**: Run only specified enhancer(s): plugin, agent, claudemd/claude-memory, docs, prompt, hooks, skills - **--verbose**: Include LOW certainty issues in report ## Workflow @@ -949,3 +951,118 @@ if (applyFixes) { - Anti-patterns flagged - Clear, actionable report - Auto-fix available for HIGH certainty issues + +--- + +# /enhance:hooks - Hook Definition Analyzer + +Analyze hook definitions for frontmatter completeness and safety cues. + +## Arguments + +Parse from $ARGUMENTS: +- **hook**: Specific hook file (default: all hooks) +- **--verbose**: Show all issues including LOW certainty + +## Workflow + +1. **Discover hooks** - Find `hooks/*.md` across the target directory +2. **Analyze each hook**: + - YAML frontmatter exists + - `name` and `description` are present +3. **Generate report** - Markdown table grouped by certainty + +## Output Format + +```markdown +## Hook Analysis: {hook-name} + +**File**: {path} +**Analyzed**: {timestamp} + +### Summary +- HIGH: {count} issues +- MEDIUM: {count} issues + +### Structure Issues ({n}) +| Issue | Fix | Certainty | +|-------|-----|-----------| +| Missing description | Add description to frontmatter | HIGH | +``` + +## Example Usage + +```bash +# Analyze all hooks +/enhance:hooks + +# Analyze a specific hook +/enhance:hooks pre-commit.md + +# Verbose output +/enhance:hooks --verbose +``` + +## Success Criteria + +- All hook definitions inspected +- Frontmatter issues flagged clearly +- Report is actionable and concise + +--- + +# /enhance:skills - SKILL.md Analyzer + +Analyze SKILL.md files for frontmatter completeness and trigger clarity. + +## Arguments + +Parse from $ARGUMENTS: +- **skill**: Specific skill directory or SKILL.md file (default: all) +- **--verbose**: Show all issues including LOW certainty + +## Workflow + +1. **Discover skills** - Find `SKILL.md` files across the target directory +2. **Analyze each skill**: + - YAML frontmatter exists + - `name` and `description` are present + - Description includes "Use when user asks" +3. **Generate report** - Markdown table grouped by certainty + +## Output Format + +```markdown +## Skill Analysis: {skill-name} + +**File**: {path} +**Analyzed**: {timestamp} + +### Summary +- HIGH: {count} issues +- MEDIUM: {count} issues + +### Structure Issues ({n}) +| Issue | Fix | Certainty | +|-------|-----|-----------| +| Missing name | Add name to frontmatter | HIGH | +``` + +## Example Usage + +```bash +# Analyze all skills +/enhance:skills + +# Analyze a specific skill +/enhance:skills enhance-docs + +# Verbose output +/enhance:skills --verbose +``` + +## Success Criteria + +- All SKILL.md files inspected +- Missing triggers are flagged +- Reports stay short and focused diff --git a/plugins/enhance/lib/enhance/hook-analyzer.js b/plugins/enhance/lib/enhance/hook-analyzer.js new file mode 100644 index 00000000..2530e111 --- /dev/null +++ b/plugins/enhance/lib/enhance/hook-analyzer.js @@ -0,0 +1,135 @@ +/** + * Hook analyzer for /enhance. + */ + +const fs = require('fs'); +const path = require('path'); +const { hookPatterns } = require('./hook-patterns'); +const { parseMarkdownFrontmatter } = require('./agent-analyzer'); + +function analyzeHook(hookPath) { + const results = { + hookName: path.basename(hookPath, '.md'), + hookPath, + structureIssues: [] + }; + + if (!fs.existsSync(hookPath)) { + results.structureIssues.push({ + issue: 'File not found', + file: hookPath, + certainty: 'HIGH', + patternId: 'file_not_found' + }); + return results; + } + + let content = ''; + try { + content = fs.readFileSync(hookPath, 'utf8'); + } catch (err) { + results.structureIssues.push({ + issue: `Failed to read file: ${err.message}`, + file: hookPath, + certainty: 'HIGH', + patternId: 'read_error' + }); + return results; + } + + const missingFm = hookPatterns.missing_frontmatter.check(content); + if (missingFm) { + results.structureIssues.push({ + ...missingFm, + file: hookPath, + certainty: hookPatterns.missing_frontmatter.certainty, + patternId: hookPatterns.missing_frontmatter.id + }); + } + + const { frontmatter } = parseMarkdownFrontmatter(content); + const missingName = hookPatterns.missing_name.check(frontmatter); + if (missingName) { + results.structureIssues.push({ + ...missingName, + file: hookPath, + certainty: hookPatterns.missing_name.certainty, + patternId: hookPatterns.missing_name.id + }); + } + + const missingDescription = hookPatterns.missing_description.check(frontmatter); + if (missingDescription) { + results.structureIssues.push({ + ...missingDescription, + file: hookPath, + certainty: hookPatterns.missing_description.certainty, + patternId: hookPatterns.missing_description.id + }); + } + + return results; +} + +function analyzeAllHooks(hooksDir) { + const results = []; + if (!fs.existsSync(hooksDir)) return results; + + const hookFiles = []; + const skipDirs = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'target']); + + function walk(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (err) { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!skipDirs.has(entry.name)) { + walk(fullPath); + } + continue; + } + + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + const parts = fullPath.split(path.sep); + if (parts.includes('hooks')) { + hookFiles.push(fullPath); + } + } + } + + walk(hooksDir); + + for (const file of hookFiles) { + results.push(analyzeHook(file)); + } + + return results; +} + +function analyze(options = {}) { + const { + hook, + hooksDir = 'plugins/enhance/hooks' + } = options; + + if (hook) { + const hookPath = hook.endsWith('.md') + ? hook + : path.join(hooksDir, `${hook}.md`); + return analyzeHook(hookPath); + } + + return analyzeAllHooks(hooksDir); +} + +module.exports = { + analyzeHook, + analyzeAllHooks, + analyze +}; diff --git a/plugins/enhance/lib/enhance/hook-patterns.js b/plugins/enhance/lib/enhance/hook-patterns.js new file mode 100644 index 00000000..472c789b --- /dev/null +++ b/plugins/enhance/lib/enhance/hook-patterns.js @@ -0,0 +1,40 @@ +/** + * Hook patterns for /enhance. + */ + +const hookPatterns = { + missing_frontmatter: { + id: 'missing_frontmatter', + certainty: 'HIGH', + check(content) { + if (!content || !content.trim().startsWith('---')) { + return { issue: 'Missing YAML frontmatter in hook file' }; + } + return null; + } + }, + missing_name: { + id: 'missing_name', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.name) { + return { issue: 'Missing name in hook frontmatter' }; + } + return null; + } + }, + missing_description: { + id: 'missing_description', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.description) { + return { issue: 'Missing description in hook frontmatter' }; + } + return null; + } + } +}; + +module.exports = { + hookPatterns +}; diff --git a/plugins/enhance/lib/enhance/index.js b/plugins/enhance/lib/enhance/index.js index 542e81fd..07539241 100644 --- a/plugins/enhance/lib/enhance/index.js +++ b/plugins/enhance/lib/enhance/index.js @@ -16,6 +16,8 @@ const projectmemoryAnalyzer = require('./projectmemory-analyzer'); const projectmemoryPatterns = require('./projectmemory-patterns'); const promptAnalyzer = require('./prompt-analyzer'); const promptPatterns = require('./prompt-patterns'); +const hookAnalyzer = require('./hook-analyzer'); +const skillAnalyzer = require('./skill-analyzer'); const reporter = require('./reporter'); const fixer = require('./fixer'); @@ -26,6 +28,8 @@ module.exports = { docsAnalyzer, projectmemoryAnalyzer, promptAnalyzer, + hookAnalyzer, + skillAnalyzer, // Pattern modules pluginPatterns, @@ -72,6 +76,16 @@ module.exports = { promptApplyFixes: promptAnalyzer.applyFixes, promptGenerateReport: promptAnalyzer.generateReport, + // Convenience exports - Hooks + analyzeHook: hookAnalyzer.analyzeHook, + analyzeAllHooks: hookAnalyzer.analyzeAllHooks, + hooksAnalyze: hookAnalyzer.analyze, + + // Convenience exports - Skills + analyzeSkill: skillAnalyzer.analyzeSkill, + analyzeAllSkills: skillAnalyzer.analyzeAllSkills, + skillsAnalyze: skillAnalyzer.analyze, + // Convenience exports - Orchestrator generateOrchestratorReport: reporter.generateOrchestratorReport, deduplicateOrchestratorFindings: reporter.deduplicateOrchestratorFindings diff --git a/plugins/enhance/lib/enhance/reporter.js b/plugins/enhance/lib/enhance/reporter.js index 7016a1f8..77b727c6 100644 --- a/plugins/enhance/lib/enhance/reporter.js +++ b/plugins/enhance/lib/enhance/reporter.js @@ -1091,7 +1091,7 @@ function generateOrchestratorReport(aggregatedResults, options = {}) { lines.push('| Enhancer | HIGH | MEDIUM | LOW | Auto-Fixable |'); lines.push('|----------|------|--------|-----|--------------|'); - const enhancerTypes = ['plugin', 'agent', 'claudemd', 'docs', 'prompt']; + const enhancerTypes = ['plugin', 'agent', 'claudemd', 'docs', 'prompt', 'hooks', 'skills']; let totalHigh = 0, totalMedium = 0, totalLow = 0, totalAutoFix = 0; for (const enhancer of enhancerTypes) { diff --git a/plugins/enhance/lib/enhance/skill-analyzer.js b/plugins/enhance/lib/enhance/skill-analyzer.js new file mode 100644 index 00000000..023ac494 --- /dev/null +++ b/plugins/enhance/lib/enhance/skill-analyzer.js @@ -0,0 +1,144 @@ +/** + * Skill analyzer for /enhance. + */ + +const fs = require('fs'); +const path = require('path'); +const { skillPatterns } = require('./skill-patterns'); +const { parseMarkdownFrontmatter } = require('./agent-analyzer'); + +function analyzeSkill(skillPath) { + const results = { + skillName: path.basename(path.dirname(skillPath)), + skillPath, + structureIssues: [], + triggerIssues: [] + }; + + if (!fs.existsSync(skillPath)) { + results.structureIssues.push({ + issue: 'File not found', + file: skillPath, + certainty: 'HIGH', + patternId: 'file_not_found' + }); + return results; + } + + let content = ''; + try { + content = fs.readFileSync(skillPath, 'utf8'); + } catch (err) { + results.structureIssues.push({ + issue: `Failed to read file: ${err.message}`, + file: skillPath, + certainty: 'HIGH', + patternId: 'read_error' + }); + return results; + } + + const missingFm = skillPatterns.missing_frontmatter.check(content); + if (missingFm) { + results.structureIssues.push({ + ...missingFm, + file: skillPath, + certainty: skillPatterns.missing_frontmatter.certainty, + patternId: skillPatterns.missing_frontmatter.id + }); + } + + const { frontmatter } = parseMarkdownFrontmatter(content); + const missingName = skillPatterns.missing_name.check(frontmatter); + if (missingName) { + results.structureIssues.push({ + ...missingName, + file: skillPath, + certainty: skillPatterns.missing_name.certainty, + patternId: skillPatterns.missing_name.id + }); + } + + const missingDescription = skillPatterns.missing_description.check(frontmatter); + if (missingDescription) { + results.structureIssues.push({ + ...missingDescription, + file: skillPath, + certainty: skillPatterns.missing_description.certainty, + patternId: skillPatterns.missing_description.id + }); + } + + const missingTrigger = skillPatterns.missing_trigger_phrase.check(frontmatter); + if (missingTrigger) { + results.triggerIssues.push({ + ...missingTrigger, + file: skillPath, + certainty: skillPatterns.missing_trigger_phrase.certainty, + patternId: skillPatterns.missing_trigger_phrase.id + }); + } + + return results; +} + +function analyzeAllSkills(skillsDir) { + const results = []; + if (!fs.existsSync(skillsDir)) return results; + + const skillFiles = []; + const skipDirs = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'target']); + + function walk(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (err) { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!skipDirs.has(entry.name)) { + walk(fullPath); + } + continue; + } + + if (entry.isFile() && entry.name === 'SKILL.md') { + skillFiles.push(fullPath); + } + } + } + + walk(skillsDir); + + for (const skillPath of skillFiles) { + results.push(analyzeSkill(skillPath)); + } + + return results; +} + +function analyze(options = {}) { + const { + skill, + skillsDir = 'plugins/enhance/skills' + } = options; + + if (skill) { + const skillPath = skill.endsWith('SKILL.md') + ? skill + : path.join(skillsDir, skill, 'SKILL.md'); + return analyzeSkill(skillPath); + } + + return analyzeAllSkills(skillsDir); +} + +module.exports = { + analyzeSkill, + analyzeAllSkills, + analyze +}; diff --git a/plugins/enhance/lib/enhance/skill-patterns.js b/plugins/enhance/lib/enhance/skill-patterns.js new file mode 100644 index 00000000..50872c58 --- /dev/null +++ b/plugins/enhance/lib/enhance/skill-patterns.js @@ -0,0 +1,51 @@ +/** + * Skill patterns for /enhance. + */ + +const skillPatterns = { + missing_frontmatter: { + id: 'missing_frontmatter', + certainty: 'HIGH', + check(content) { + if (!content || !content.trim().startsWith('---')) { + return { issue: 'Missing YAML frontmatter in SKILL.md' }; + } + return null; + } + }, + missing_name: { + id: 'missing_name', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.name) { + return { issue: 'Missing name in SKILL.md frontmatter' }; + } + return null; + } + }, + missing_description: { + id: 'missing_description', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.description) { + return { issue: 'Missing description in SKILL.md frontmatter' }; + } + return null; + } + }, + missing_trigger_phrase: { + id: 'missing_trigger_phrase', + certainty: 'MEDIUM', + check(frontmatter) { + if (!frontmatter || !frontmatter.description) return null; + if (!/use when user asks/i.test(frontmatter.description)) { + return { issue: 'Description missing "Use when user asks" trigger phrase' }; + } + return null; + } + } +}; + +module.exports = { + skillPatterns +}; diff --git a/plugins/enhance/lib/index.js b/plugins/enhance/lib/index.js index 646eb350..07706b6c 100644 --- a/plugins/enhance/lib/index.js +++ b/plugins/enhance/lib/index.js @@ -26,6 +26,7 @@ const policyQuestions = require('./sources/policy-questions'); const crossPlatform = require('./cross-platform'); const enhance = require('./enhance'); const repoMap = require('./repo-map'); +const perf = require('./perf'); /** * Platform detection and verification utilities @@ -228,6 +229,7 @@ module.exports = { xplat, enhance, repoMap, + perf, // Direct module access for backward compatibility detectPlatform, diff --git a/plugins/enhance/lib/perf/analyzer/index.js b/plugins/enhance/lib/perf/analyzer/index.js new file mode 100644 index 00000000..87fd5c4f --- /dev/null +++ b/plugins/enhance/lib/perf/analyzer/index.js @@ -0,0 +1,22 @@ +/** + * Perf analysis helpers. + * + * @module lib/perf/analyzer + */ + +/** + * Build a compact summary of perf findings. + * @param {object} input + * @returns {object} + */ +function summarize(input = {}) { + return { + summary: input.summary || '', + recommendations: input.recommendations || [], + risks: input.risks || [] + }; +} + +module.exports = { + summarize +}; diff --git a/plugins/enhance/lib/perf/argument-parser.js b/plugins/enhance/lib/perf/argument-parser.js new file mode 100644 index 00000000..46b04d35 --- /dev/null +++ b/plugins/enhance/lib/perf/argument-parser.js @@ -0,0 +1,65 @@ +/** + * Argument parsing helper for /perf. + * + * @module lib/perf/argument-parser + */ + +function parseArguments(raw) { + if (!raw || typeof raw !== 'string') return []; + + const args = []; + let current = ''; + let quote = null; + let escaped = false; + + for (let i = 0; i < raw.length; i++) { + const ch = raw[i]; + + if (escaped) { + current += ch; + escaped = false; + continue; + } + + if (ch === '\\') { + if (quote) { + escaped = true; + continue; + } + } + + if (quote) { + if (ch === quote) { + quote = null; + } else { + current += ch; + } + continue; + } + + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + + if (/\s/.test(ch)) { + if (current) { + args.push(current); + current = ''; + } + continue; + } + + current += ch; + } + + if (current) { + args.push(current); + } + + return args; +} + +module.exports = { + parseArguments +}; diff --git a/plugins/enhance/lib/perf/baseline-comparator.js b/plugins/enhance/lib/perf/baseline-comparator.js new file mode 100644 index 00000000..7e71220a --- /dev/null +++ b/plugins/enhance/lib/perf/baseline-comparator.js @@ -0,0 +1,50 @@ +/** + * Baseline comparison helpers + * + * @module lib/perf/baseline-comparator + */ + +/** + * Compute delta between baseline and current metrics. + * Supports flat numeric values under baseline.metrics/current.metrics. + * + * @param {object} baseline + * @param {object} current + * @returns {object} + */ +function compareBaselines(baseline, current) { + const baselineMetrics = baseline?.metrics || {}; + const currentMetrics = current?.metrics || {}; + const keys = new Set([ + ...Object.keys(baselineMetrics), + ...Object.keys(currentMetrics) + ]); + + const deltas = {}; + for (const key of keys) { + const baseValue = baselineMetrics[key]; + const currentValue = currentMetrics[key]; + + if (typeof baseValue === 'number' && typeof currentValue === 'number') { + const delta = currentValue - baseValue; + const percent = baseValue === 0 ? null : delta / baseValue; + deltas[key] = { baseline: baseValue, current: currentValue, delta, percent }; + } else { + deltas[key] = { + baseline: baseValue ?? null, + current: currentValue ?? null, + delta: null, + percent: null + }; + } + } + + return { + comparedAt: new Date().toISOString(), + metrics: deltas + }; +} + +module.exports = { + compareBaselines +}; diff --git a/plugins/enhance/lib/perf/baseline-store.js b/plugins/enhance/lib/perf/baseline-store.js new file mode 100644 index 00000000..f8c8a21f --- /dev/null +++ b/plugins/enhance/lib/perf/baseline-store.js @@ -0,0 +1,127 @@ +/** + * Baseline storage utilities for /perf + * + * Stores baselines under: + * - {state-dir}/perf/baselines/{version}.json + * + * @module lib/perf/baseline-store + */ + +const fs = require('fs'); +const path = require('path'); +const { getStateDir } = require('../platform/state-dir'); +const { validateBaseline, assertValid } = require('./schemas'); + +const BASELINE_DIR = 'baselines'; + +function assertSafeBaselineVersion(version) { + if (!version || typeof version !== 'string') { + throw new Error('Baseline version is required'); + } + if (version.includes('..') || version.includes('/') || version.includes('\\') || version.includes('\0')) { + throw new Error('Baseline version contains invalid characters'); + } + if (!/^[a-zA-Z0-9._+-]+$/.test(version)) { + throw new Error('Baseline version contains invalid characters'); + } + return version; +} + +/** + * Get baseline directory path + * @param {string} basePath + * @returns {string} + */ +function getBaselineDir(basePath = process.cwd()) { + return path.join(basePath, getStateDir(basePath), 'perf', BASELINE_DIR); +} + +/** + * Ensure baseline directory exists + * @param {string} basePath + * @returns {string} + */ +function ensureBaselineDir(basePath = process.cwd()) { + const dir = getBaselineDir(basePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + return dir; +} + +/** + * Build baseline file path + * @param {string} version + * @param {string} basePath + * @returns {string} + */ +function getBaselinePath(version, basePath = process.cwd()) { + const safeVersion = assertSafeBaselineVersion(version); + return path.join(ensureBaselineDir(basePath), `${safeVersion}.json`); +} + +/** + * List baseline versions + * @param {string} basePath + * @returns {string[]} + */ +function listBaselines(basePath = process.cwd()) { + const dir = ensureBaselineDir(basePath); + return fs.readdirSync(dir) + .filter(file => file.endsWith('.json')) + .map(file => path.basename(file, '.json')) + .sort(); +} + +/** + * Read baseline file + * @param {string} version + * @param {string} basePath + * @returns {object|null} + */ +function readBaseline(version, basePath = process.cwd()) { + const baselinePath = getBaselinePath(version, basePath); + if (!fs.existsSync(baselinePath)) { + return null; + } + try { + const parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf8')); + const validation = validateBaseline(parsed); + if (!validation.ok) { + console.error(`[CRITICAL] Invalid baseline file at ${baselinePath}: ${validation.errors.join(', ')}`); + return null; + } + return parsed; + } catch (error) { + console.error(`[CRITICAL] Corrupted baseline file at ${baselinePath}: ${error.message}`); + return null; + } +} + +/** + * Write baseline file (overwrites existing) + * @param {string} version + * @param {object} baseline + * @param {string} basePath + * @returns {boolean} + */ +function writeBaseline(version, baseline, basePath = process.cwd()) { + const baselinePath = getBaselinePath(version, basePath); + const payload = { + version, + recordedAt: new Date().toISOString(), + ...baseline + }; + assertValid(validateBaseline(payload), 'Invalid baseline payload'); + fs.writeFileSync(baselinePath, JSON.stringify(payload, null, 2), 'utf8'); + return true; +} + +module.exports = { + getBaselineDir, + ensureBaselineDir, + getBaselinePath, + listBaselines, + readBaseline, + writeBaseline +}; diff --git a/plugins/enhance/lib/perf/benchmark-runner.js b/plugins/enhance/lib/perf/benchmark-runner.js new file mode 100644 index 00000000..c245815c --- /dev/null +++ b/plugins/enhance/lib/perf/benchmark-runner.js @@ -0,0 +1,107 @@ +/** + * Sequential benchmark runner utilities. + * + * @module lib/perf/benchmark-runner + */ + +const { execSync } = require('child_process'); +const { validateBaseline } = require('./schemas'); + +const DEFAULT_MIN_DURATION = 60; +const BINARY_SEARCH_MIN_DURATION = 30; + +/** + * Normalize benchmark options and enforce minimum durations. + * @param {object} options + * @returns {object} + */ +function normalizeBenchmarkOptions(options = {}) { + const mode = options.mode || 'full'; + const minDuration = mode === 'binary-search' + ? BINARY_SEARCH_MIN_DURATION + : DEFAULT_MIN_DURATION; + + const duration = Math.max(options.duration || minDuration, minDuration); + return { + ...options, + mode, + duration, + warmup: options.warmup || 10 + }; +} + +/** + * Run a benchmark command synchronously (sequential only). + * @param {string} command + * @param {object} options + * @returns {{ success: boolean, output: string }} + */ +function runBenchmark(command, options = {}) { + if (!command || typeof command !== 'string') { + throw new Error('Benchmark command must be a non-empty string'); + } + + const normalized = normalizeBenchmarkOptions(options); + const env = { ...process.env, ...normalized.env }; + + const output = execSync(command, { + stdio: 'pipe', + encoding: 'utf8', + env + }); + + return { + success: true, + output, + duration: normalized.duration, + warmup: normalized.warmup, + mode: normalized.mode + }; +} + +/** + * Parse metrics from benchmark output using PERF_METRICS markers. + * @param {string} output + * @returns {{ ok: boolean, metrics?: object, error?: string }} + */ +function parseMetrics(output) { + if (typeof output !== 'string') { + return { ok: false, error: 'Output must be a string' }; + } + + const startMarker = 'PERF_METRICS_START'; + const endMarker = 'PERF_METRICS_END'; + const startIndex = output.indexOf(startMarker); + const endIndex = output.indexOf(endMarker); + + if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) { + return { ok: false, error: 'Metrics markers not found' }; + } + + const jsonStart = startIndex + startMarker.length; + const raw = output.slice(jsonStart, endIndex).trim(); + + try { + const parsed = JSON.parse(raw); + const validation = validateBaseline({ + version: 'temp', + recordedAt: new Date().toISOString(), + command: 'temp', + metrics: parsed + }); + if (!validation.ok) { + return { ok: false, error: `Invalid metrics: ${validation.errors.join(', ')}` }; + } + return { ok: true, metrics: parsed }; + } catch (error) { + return { ok: false, error: `Failed to parse metrics JSON: ${error.message}` }; + } +} + +module.exports = { + DEFAULT_MIN_DURATION, + BINARY_SEARCH_MIN_DURATION, + normalizeBenchmarkOptions, + runBenchmark, + parseMetrics +}; diff --git a/plugins/enhance/lib/perf/breaking-point-finder.js b/plugins/enhance/lib/perf/breaking-point-finder.js new file mode 100644 index 00000000..d7239cce --- /dev/null +++ b/plugins/enhance/lib/perf/breaking-point-finder.js @@ -0,0 +1,52 @@ +/** + * Binary search helper for breaking point discovery. + * + * @module lib/perf/breaking-point-finder + */ + +/** + * Find breaking point using binary search. + * The runner should return { ok: boolean, data?: any }. + * + * @param {object} options + * @param {number} options.min + * @param {number} options.max + * @param {(value:number)=>Promise<{ok:boolean,data?:any}>} options.runner + * @returns {Promise<{breakingPoint:number|null, attempts:number, history:Array}>} + */ +async function findBreakingPoint({ min, max, runner }) { + if (typeof min !== 'number' || typeof max !== 'number') { + throw new Error('min and max must be numbers'); + } + if (typeof runner !== 'function') { + throw new Error('runner must be a function'); + } + + let low = min; + let high = max; + let breakingPoint = null; + const history = []; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const result = await runner(mid); + history.push({ value: mid, ok: result.ok }); + + if (result.ok) { + low = mid + 1; + } else { + breakingPoint = mid; + high = mid - 1; + } + } + + return { + breakingPoint, + attempts: history.length, + history + }; +} + +module.exports = { + findBreakingPoint +}; diff --git a/plugins/enhance/lib/perf/breaking-point-runner.js b/plugins/enhance/lib/perf/breaking-point-runner.js new file mode 100644 index 00000000..0f15d5af --- /dev/null +++ b/plugins/enhance/lib/perf/breaking-point-runner.js @@ -0,0 +1,60 @@ +/** + * Breaking point runner wrapper for /perf. + * + * @module lib/perf/breaking-point-runner + */ + +const { runBenchmark, parseMetrics, BINARY_SEARCH_MIN_DURATION } = require('./benchmark-runner'); +const { findBreakingPoint } = require('./breaking-point-finder'); + +/** + * Run a binary search to find the breaking point for a numeric parameter. + * The benchmark command should accept the value via an env var. + * + * @param {object} options + * @param {string} options.command + * @param {string} options.paramEnv + * @param {number} options.min + * @param {number} options.max + * @returns {Promise<{breakingPoint:number|null, attempts:number, history:Array}>} + */ +async function runBreakingPointSearch(options) { + const { command, paramEnv, min, max } = options || {}; + + if (!command || typeof command !== 'string') { + throw new Error('command must be a non-empty string'); + } + if (!paramEnv || typeof paramEnv !== 'string') { + throw new Error('paramEnv must be a non-empty string'); + } + if (typeof min !== 'number' || typeof max !== 'number') { + throw new Error('min and max must be numbers'); + } + + const runner = async (value) => { + try { + const result = runBenchmark(command, { + mode: 'binary-search', + duration: BINARY_SEARCH_MIN_DURATION, + env: { + [paramEnv]: String(value) + } + }); + + const parsed = parseMetrics(result.output); + if (!parsed.ok) { + return { ok: false, data: { error: parsed.error } }; + } + + return { ok: true, data: { metrics: parsed.metrics } }; + } catch (error) { + return { ok: false, data: { error: error.message } }; + } + }; + + return findBreakingPoint({ min, max, runner }); +} + +module.exports = { + runBreakingPointSearch +}; diff --git a/plugins/enhance/lib/perf/checkpoint.js b/plugins/enhance/lib/perf/checkpoint.js new file mode 100644 index 00000000..8926f855 --- /dev/null +++ b/plugins/enhance/lib/perf/checkpoint.js @@ -0,0 +1,99 @@ +/** + * Git checkpoint helper for /perf phases. + * + * @module lib/perf/checkpoint + */ + +const { execSync, execFileSync } = require('child_process'); + +/** + * Check if git repo is clean. + * @returns {boolean} + */ +function isWorkingTreeClean() { + const output = execSync('git status --porcelain', { encoding: 'utf8' }).trim(); + return output.length === 0; +} + +/** + * Build checkpoint commit message. + * @param {object} input + * @param {string} input.phase + * @param {string} input.id + * @param {string} [input.baselineVersion] + * @param {string} [input.deltaSummary] + * @returns {string} + */ +function buildCheckpointMessage(input) { + if (!input || typeof input !== 'object') { + throw new Error('Checkpoint input must be an object'); + } + const { phase, id, baselineVersion, deltaSummary } = input; + + if (!phase || typeof phase !== 'string') { + throw new Error('phase is required'); + } + if (!id || typeof id !== 'string') { + throw new Error('id is required'); + } + + const baseline = baselineVersion || 'n/a'; + const delta = deltaSummary || 'n/a'; + return `perf: phase ${phase} [${id}] baseline=${baseline} delta=${delta}`; +} + +/** + * Get the most recent git commit message. + * @returns {string|null} + */ +function getLastCommitMessage() { + try { + return execSync('git log -1 --pretty=%B', { encoding: 'utf8' }).trim(); + } catch { + return null; + } +} + +/** + * Check if the next checkpoint would duplicate the last commit. + * @param {string} message + * @returns {boolean} + */ +function isDuplicateCheckpoint(message) { + const last = getLastCommitMessage(); + if (!last) return false; + return last.trim() === String(message || '').trim(); +} + +/** + * Commit a checkpoint for a perf phase. + * @param {object} input + * @returns {{ ok: boolean, message?: string, reason?: string }} + */ +function commitCheckpoint(input) { + try { + execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' }); + } catch { + return { ok: false, reason: 'not a git repo' }; + } + + if (isWorkingTreeClean()) { + return { ok: false, reason: 'nothing to commit' }; + } + + const message = buildCheckpointMessage(input); + if (isDuplicateCheckpoint(message)) { + return { ok: false, reason: 'duplicate checkpoint' }; + } + execFileSync('git', ['add', '-A'], { stdio: 'ignore' }); + execFileSync('git', ['commit', '-m', message], { stdio: 'ignore' }); + return { ok: true, message }; +} + +module.exports = { + isWorkingTreeClean, + buildCheckpointMessage, + getLastCommitMessage, + isDuplicateCheckpoint, + commitCheckpoint +}; diff --git a/plugins/enhance/lib/perf/code-paths.js b/plugins/enhance/lib/perf/code-paths.js new file mode 100644 index 00000000..ece2c8bf --- /dev/null +++ b/plugins/enhance/lib/perf/code-paths.js @@ -0,0 +1,86 @@ +/** + * Code-path discovery helpers for /perf. + * + * @module lib/perf/code-paths + */ + +const DEFAULT_STOPWORDS = new Set([ + 'the', 'and', 'for', 'with', 'from', 'that', 'this', 'these', 'those', + 'into', 'over', 'under', 'than', 'then', 'when', 'where', 'what', 'which', + 'your', 'you', 'our', 'their', 'there', 'have', 'has', 'had', 'will', + 'would', 'should', 'could', 'about', 'across', 'after', 'before', 'while', + 'perf', 'performance', 'investigation', 'baseline', 'benchmark', 'scenario' +]); + +function normalizeKeywords(text) { + if (!text || typeof text !== 'string') return []; + const tokens = text + .toLowerCase() + .split(/[^a-z0-9]+/g) + .filter(Boolean) + .filter(token => token.length > 2) + .filter(token => !DEFAULT_STOPWORDS.has(token)); + + return Array.from(new Set(tokens)); +} + +function scoreEntry(entry, keywords) { + let score = 0; + if (!entry || keywords.length === 0) return score; + + const haystack = [ + entry.file || '', + ...(entry.symbols || []) + ].join(' ').toLowerCase(); + + for (const keyword of keywords) { + if (haystack.includes(keyword)) score += 1; + } + + return score; +} + +function extractSymbols(fileData) { + if (!fileData || !fileData.symbols) return []; + const symbols = []; + for (const group of Object.values(fileData.symbols)) { + if (!Array.isArray(group)) continue; + for (const symbol of group) { + if (symbol && symbol.name) symbols.push(symbol.name); + } + } + return symbols; +} + +function collectCodePaths(repoMap, scenario, limit = 12) { + if (!repoMap || !repoMap.files) { + return { keywords: normalizeKeywords(scenario), paths: [] }; + } + + const keywords = normalizeKeywords(scenario); + const candidates = []; + + for (const [file, data] of Object.entries(repoMap.files)) { + const symbols = extractSymbols(data); + const entry = { file, symbols }; + const score = scoreEntry(entry, keywords); + if (score <= 0) continue; + candidates.push({ ...entry, score }); + } + + candidates.sort((a, b) => b.score - a.score || a.file.localeCompare(b.file)); + + return { + keywords, + paths: candidates.slice(0, limit).map(item => ({ + file: item.file, + score: item.score, + symbols: item.symbols.slice(0, 8) + })) + }; +} + +module.exports = { + normalizeKeywords, + collectCodePaths +}; diff --git a/plugins/enhance/lib/perf/consolidation.js b/plugins/enhance/lib/perf/consolidation.js new file mode 100644 index 00000000..f8c292da --- /dev/null +++ b/plugins/enhance/lib/perf/consolidation.js @@ -0,0 +1,37 @@ +/** + * Baseline consolidation helper. + * + * @module lib/perf/consolidation + */ + +const baselineStore = require('./baseline-store'); + +/** + * Consolidate a baseline for a version (overwrite existing). + * @param {object} input + * @param {string} input.version + * @param {object} input.baseline + * @param {string} [basePath] + * @returns {{ version: string, path: string }} + */ +function consolidateBaseline(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('consolidateBaseline requires an input object'); + } + const { version, baseline } = input; + + if (!version || typeof version !== 'string') { + throw new Error('version is required'); + } + if (!baseline || typeof baseline !== 'object') { + throw new Error('baseline is required'); + } + + baselineStore.writeBaseline(version, baseline, basePath); + const path = baselineStore.getBaselinePath(version, basePath); + return { version, path }; +} + +module.exports = { + consolidateBaseline +}; diff --git a/plugins/enhance/lib/perf/constraint-runner.js b/plugins/enhance/lib/perf/constraint-runner.js new file mode 100644 index 00000000..a5c5f6a5 --- /dev/null +++ b/plugins/enhance/lib/perf/constraint-runner.js @@ -0,0 +1,69 @@ +/** + * Constraint testing runner for /perf. + * + * @module lib/perf/constraint-runner + */ + +const { runBenchmark, parseMetrics, DEFAULT_MIN_DURATION } = require('./benchmark-runner'); +const { compareBaselines } = require('./baseline-comparator'); + +/** + * Run baseline and constrained benchmarks sequentially. + * Constraints are provided via env vars to keep it cross-platform. + * + * @param {object} options + * @param {string} options.command + * @param {object} options.constraints + * @param {object} [options.env] + * @returns {{ constraints: object, baseline: object, constrained: object, delta: object }} + */ +function runConstraintTest(options) { + const { command, constraints, env } = options || {}; + + if (!command || typeof command !== 'string') { + throw new Error('command must be a non-empty string'); + } + if (!constraints || typeof constraints !== 'object' || Array.isArray(constraints)) { + throw new Error('constraints must be an object'); + } + + const baselineResult = runBenchmark(command, { + duration: DEFAULT_MIN_DURATION, + env: { + ...env + } + }); + const baselineMetrics = parseMetrics(baselineResult.output); + if (!baselineMetrics.ok) { + throw new Error(`Baseline metrics parse failed: ${baselineMetrics.error}`); + } + + const constrainedResult = runBenchmark(command, { + duration: DEFAULT_MIN_DURATION, + env: { + ...env, + PERF_CPU_LIMIT: constraints.cpu, + PERF_MEMORY_LIMIT: constraints.memory + } + }); + const constrainedMetrics = parseMetrics(constrainedResult.output); + if (!constrainedMetrics.ok) { + throw new Error(`Constrained metrics parse failed: ${constrainedMetrics.error}`); + } + + const delta = compareBaselines( + { metrics: baselineMetrics.metrics }, + { metrics: constrainedMetrics.metrics } + ); + + return { + constraints, + baseline: { metrics: baselineMetrics.metrics }, + constrained: { metrics: constrainedMetrics.metrics }, + delta + }; +} + +module.exports = { + runConstraintTest +}; diff --git a/plugins/enhance/lib/perf/experiment-runner.js b/plugins/enhance/lib/perf/experiment-runner.js new file mode 100644 index 00000000..fbee670d --- /dev/null +++ b/plugins/enhance/lib/perf/experiment-runner.js @@ -0,0 +1,32 @@ +/** + * Experiment runner utilities. + * + * @module lib/perf/experiment-runner + */ + +/** + * Run experiments sequentially (never parallel). + * @param {Array} experiments + * @param {(experiment:object)=>Promise} runner + * @returns {Promise<{results:Array}>} + */ +async function runExperiments(experiments, runner) { + if (!Array.isArray(experiments)) { + throw new Error('experiments must be an array'); + } + if (typeof runner !== 'function') { + throw new Error('runner must be a function'); + } + + const results = []; + for (const experiment of experiments) { + const result = await runner(experiment); + results.push(result); + } + + return { results }; +} + +module.exports = { + runExperiments +}; diff --git a/plugins/enhance/lib/perf/index.js b/plugins/enhance/lib/perf/index.js new file mode 100644 index 00000000..2a8a689a --- /dev/null +++ b/plugins/enhance/lib/perf/index.js @@ -0,0 +1,41 @@ +/** + * Performance investigation utilities + * + * @module lib/perf + */ + +const investigationState = require('./investigation-state'); +const baselineStore = require('./baseline-store'); +const baselineComparator = require('./baseline-comparator'); +const benchmarkRunner = require('./benchmark-runner'); +const breakingPointFinder = require('./breaking-point-finder'); +const breakingPointRunner = require('./breaking-point-runner'); +const experimentRunner = require('./experiment-runner'); +const constraintRunner = require('./constraint-runner'); +const checkpoint = require('./checkpoint'); +const profilingRunner = require('./profiling-runner'); +const optimizationRunner = require('./optimization-runner'); +const consolidation = require('./consolidation'); +const profilers = require('./profilers'); +const analyzer = require('./analyzer'); +const argumentParser = require('./argument-parser'); +const codePaths = require('./code-paths'); + +module.exports = { + investigationState, + baselineStore, + baselineComparator, + benchmarkRunner, + breakingPointFinder, + breakingPointRunner, + experimentRunner, + constraintRunner, + checkpoint, + profilingRunner, + optimizationRunner, + consolidation, + profilers, + analyzer, + argumentParser, + codePaths +}; diff --git a/plugins/enhance/lib/perf/investigation-state.js b/plugins/enhance/lib/perf/investigation-state.js new file mode 100644 index 00000000..3bb091df --- /dev/null +++ b/plugins/enhance/lib/perf/investigation-state.js @@ -0,0 +1,788 @@ +/** + * Performance investigation state management + * + * Stores investigation state and logs under the platform-aware state directory: + * - {state-dir}/perf/investigation.json + * - {state-dir}/perf/investigations/{id}.md + * + * @module lib/perf/investigation-state + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { getStateDir } = require('../platform/state-dir'); +const { validateInvestigationState, assertValid } = require('./schemas'); + +const SCHEMA_VERSION = 1; +const INVESTIGATION_FILE = 'investigation.json'; +const LOG_DIR = 'investigations'; +const BASELINE_DIR = 'baselines'; + +const PHASES = [ + 'setup', + 'baseline', + 'breaking-point', + 'constraints', + 'hypotheses', + 'code-paths', + 'profiling', + 'optimization', + 'decision', + 'consolidation' +]; + +/** + * Validate and resolve path to prevent path traversal attacks + * @param {string} basePath - Base directory path + * @returns {string} Validated absolute path + */ +function validatePath(basePath) { + if (typeof basePath !== 'string' || basePath.length === 0) { + throw new Error('Path must be a non-empty string'); + } + const resolved = path.resolve(basePath); + if (resolved.includes('\0')) { + throw new Error('Path contains invalid null byte'); + } + return resolved; +} + +/** + * Validate that target path is within base directory + * @param {string} targetPath - Target file path + * @param {string} basePath - Base directory + */ +function validatePathWithinBase(targetPath, basePath) { + const resolvedTarget = path.resolve(targetPath); + const resolvedBase = path.resolve(basePath); + if (!resolvedTarget.startsWith(resolvedBase + path.sep) && resolvedTarget !== resolvedBase) { + throw new Error('Path traversal detected'); + } +} + +function assertSafeInvestigationId(id) { + if (!id || typeof id !== 'string') { + throw new Error('Investigation id is required'); + } + if (id.includes('..') || id.includes('/') || id.includes('\\') || id.includes('\0')) { + throw new Error('Investigation id contains invalid characters'); + } + if (!/^[a-zA-Z0-9._-]+$/.test(id)) { + throw new Error('Investigation id contains invalid characters'); + } + return id; +} + +/** + * Generate a unique investigation ID + * @returns {string} + */ +function generateInvestigationId() { + const now = new Date(); + const date = now.toISOString().slice(0, 10).replace(/-/g, ''); + const time = now.toISOString().slice(11, 19).replace(/:/g, ''); + const random = crypto.randomBytes(4).toString('hex'); + return `perf-${date}-${time}-${random}`; +} + +/** + * Get perf state directory path + * @param {string} basePath + * @returns {string} + */ +function getPerfDir(basePath = process.cwd()) { + const validatedBase = validatePath(basePath); + const perfDir = path.join(validatedBase, getStateDir(basePath), 'perf'); + validatePathWithinBase(perfDir, validatedBase); + return perfDir; +} + +/** + * Ensure perf directories exist + * @param {string} basePath + * @returns {{ perfDir: string, logDir: string, baselineDir: string }} + */ +function ensurePerfDirs(basePath = process.cwd()) { + const perfDir = getPerfDir(basePath); + const logDir = path.join(perfDir, LOG_DIR); + const baselineDir = path.join(perfDir, BASELINE_DIR); + + if (!fs.existsSync(perfDir)) { + fs.mkdirSync(perfDir, { recursive: true }); + } + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + if (!fs.existsSync(baselineDir)) { + fs.mkdirSync(baselineDir, { recursive: true }); + } + + return { perfDir, logDir, baselineDir }; +} + +/** + * Get path to investigation.json + * @param {string} basePath + * @returns {string} + */ +function getInvestigationPath(basePath = process.cwd()) { + const perfDir = getPerfDir(basePath); + return path.join(perfDir, INVESTIGATION_FILE); +} + +/** + * Get path to investigation log + * @param {string} id + * @param {string} basePath + * @returns {string} + */ +function getInvestigationLogPath(id, basePath = process.cwd()) { + const safeId = assertSafeInvestigationId(id); + const { logDir } = ensurePerfDirs(basePath); + return path.join(logDir, `${safeId}.md`); +} + +/** + * Read investigation.json + * @param {string} basePath + * @returns {object|null} + */ +function readInvestigation(basePath = process.cwd()) { + const investigationPath = getInvestigationPath(basePath); + if (!fs.existsSync(investigationPath)) { + return null; + } + try { + const parsed = JSON.parse(fs.readFileSync(investigationPath, 'utf8')); + const validation = validateInvestigationState(parsed); + if (!validation.ok) { + console.error(`[CRITICAL] Invalid investigation state at ${investigationPath}: ${validation.errors.join(', ')}`); + return null; + } + return parsed; + } catch (error) { + console.error(`[CRITICAL] Corrupted investigation.json at ${investigationPath}: ${error.message}`); + return null; + } +} + +/** + * Write investigation.json + * @param {object} state + * @param {string} basePath + * @returns {boolean} + */ +function writeInvestigation(state, basePath = process.cwd()) { + ensurePerfDirs(basePath); + const investigationPath = getInvestigationPath(basePath); + const nextState = { ...state, updatedAt: new Date().toISOString() }; + assertValid(validateInvestigationState(nextState), 'Invalid investigation state'); + fs.writeFileSync(investigationPath, JSON.stringify(nextState, null, 2), 'utf8'); + return true; +} + +/** + * Update investigation.json with partial updates + * @param {object} updates + * @param {string} basePath + * @returns {object|null} + */ +function updateInvestigation(updates, basePath = process.cwd()) { + const current = readInvestigation(basePath) || {}; + const nextState = { ...current }; + + for (const [key, value] of Object.entries(updates)) { + if (value === null) { + nextState[key] = null; + } else if ( + value && typeof value === 'object' && !Array.isArray(value) && + nextState[key] && typeof nextState[key] === 'object' && !Array.isArray(nextState[key]) + ) { + nextState[key] = { ...nextState[key], ...value }; + } else { + nextState[key] = value; + } + } + + writeInvestigation(nextState, basePath); + return readInvestigation(basePath); +} + +/** + * Initialize a new investigation + * @param {object} options + * @param {string} basePath + * @returns {object} + */ +function initializeInvestigation(options = {}, basePath = process.cwd()) { + const id = options.id || generateInvestigationId(); + const phase = options.phase || PHASES[0]; + + if (!PHASES.includes(phase)) { + throw new Error(`Invalid perf phase: ${phase}`); + } + + const state = { + schemaVersion: SCHEMA_VERSION, + id, + status: 'in_progress', + phase, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + scenario: { + description: options.scenario || '', + metrics: options.metrics || [], + successCriteria: options.successCriteria || '', + scenarios: Array.isArray(options.scenarios) ? options.scenarios : [] + }, + baselines: [], + hypotheses: [], + codePaths: [], + experiments: [], + results: [], + breakingPoint: null, + breakingPointHistory: [], + constraintResults: [], + profilingResults: [], + decision: null + }; + + assertValid(validateInvestigationState(state), 'Invalid initial investigation state'); + writeInvestigation(state, basePath); + return state; +} + +/** + * Append a line to the investigation log + * @param {string} id + * @param {string} content + * @param {string} basePath + */ +function appendInvestigationLog(id, content, basePath = process.cwd()) { + if (!content) return; + const logPath = getInvestigationLogPath(id, basePath); + const entry = content.endsWith('\n') ? content : `${content}\n`; + fs.appendFileSync(logPath, entry, 'utf8'); +} + +/** + * Append a baseline section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.command + * @param {object} input.metrics + * @param {string} input.baselinePath + * @param {string} [input.date] + * @param {string} basePath + */ +function appendBaselineLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendBaselineLog requires an input object'); + } + + const { id, userQuote, command, metrics, baselinePath, date, scenarios } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendBaselineLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendBaselineLog requires a non-empty userQuote'); + } + if (!command || typeof command !== 'string') { + throw new Error('appendBaselineLog requires a non-empty command'); + } + if (!metrics || typeof metrics !== 'object' || Array.isArray(metrics)) { + throw new Error('appendBaselineLog requires a metrics object'); + } + if (!baselinePath || typeof baselinePath !== 'string') { + throw new Error('appendBaselineLog requires a baselinePath'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const metricsText = JSON.stringify(metrics); + const scenarioText = Array.isArray(scenarios) && scenarios.length > 0 + ? scenarios.map((scenario) => scenario.name).filter(Boolean).join(', ') + : ''; + + const entry = [ + `## Baseline - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + scenarioText ? `- Scenarios: ${scenarioText}` : null, + `- Baseline command: \`${command}\``, + `- Metrics: ${metricsText}`, + '', + '**Evidence**', + `- Baseline file: ${baselinePath}`, + '' + ].filter(Boolean).join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a profiling section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.tool + * @param {string} input.command + * @param {string[]} input.artifacts + * @param {string[]} input.hotspots + * @param {string} [input.date] + * @param {string} basePath + */ +function appendProfilingLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendProfilingLog requires an input object'); + } + + const { id, userQuote, tool, command, artifacts, hotspots, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendProfilingLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendProfilingLog requires a non-empty userQuote'); + } + if (!tool || typeof tool !== 'string') { + throw new Error('appendProfilingLog requires a tool'); + } + if (!command || typeof command !== 'string') { + throw new Error('appendProfilingLog requires a command'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const artifactList = Array.isArray(artifacts) ? artifacts : []; + const hotspotList = Array.isArray(hotspots) ? hotspots : []; + + const entry = [ + `## Profiling - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Tool: ${tool}`, + `- Command: \`${command}\``, + '', + '**Evidence**', + artifactList.length ? `- Artifacts: ${artifactList.join(', ')}` : '- Artifacts: n/a', + hotspotList.length ? `- Hotspots: ${hotspotList.join(', ')}` : '- Hotspots: n/a', + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a decision section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.verdict + * @param {string} input.rationale + * @param {string} [input.date] + * @param {string} basePath + */ +function appendDecisionLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendDecisionLog requires an input object'); + } + + const { id, userQuote, verdict, rationale, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendDecisionLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendDecisionLog requires a non-empty userQuote'); + } + if (!verdict || typeof verdict !== 'string') { + throw new Error('appendDecisionLog requires a verdict'); + } + if (!rationale || typeof rationale !== 'string') { + throw new Error('appendDecisionLog requires a rationale'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + + const entry = [ + `## Decision - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Verdict: ${verdict}`, + `- Rationale: ${rationale}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a setup section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.scenario + * @param {string} input.command + * @param {string} input.version + * @param {string} [input.date] + * @param {string} basePath + */ +function appendSetupLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendSetupLog requires an input object'); + } + + const { id, userQuote, scenario, command, version, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendSetupLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendSetupLog requires a non-empty userQuote'); + } + if (!scenario || typeof scenario !== 'string') { + throw new Error('appendSetupLog requires a scenario'); + } + if (!command || typeof command !== 'string') { + throw new Error('appendSetupLog requires a command'); + } + if (!version || typeof version !== 'string') { + throw new Error('appendSetupLog requires a version'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Setup - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Scenario: ${scenario}`, + `- Command: \`${command}\``, + `- Version: ${version}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a breaking point section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.paramEnv + * @param {number} input.min + * @param {number} input.max + * @param {number|null} input.breakingPoint + * @param {string} [input.date] + * @param {string} basePath + */ +function appendBreakingPointLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendBreakingPointLog requires an input object'); + } + const { id, userQuote, paramEnv, min, max, breakingPoint, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendBreakingPointLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendBreakingPointLog requires a non-empty userQuote'); + } + if (!paramEnv || typeof paramEnv !== 'string') { + throw new Error('appendBreakingPointLog requires a paramEnv'); + } + if (typeof min !== 'number' || typeof max !== 'number') { + throw new Error('appendBreakingPointLog requires numeric min/max'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Breaking Point - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Param env: ${paramEnv}`, + `- Range: ${min}..${max}`, + `- Breaking point: ${breakingPoint ?? 'n/a'}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a constraints section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {object} input.constraints + * @param {object} input.delta + * @param {string} [input.date] + * @param {string} basePath + */ +function appendConstraintLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendConstraintLog requires an input object'); + } + const { id, userQuote, constraints, delta, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendConstraintLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendConstraintLog requires a non-empty userQuote'); + } + if (!constraints || typeof constraints !== 'object') { + throw new Error('appendConstraintLog requires constraints'); + } + if (!delta || typeof delta !== 'object') { + throw new Error('appendConstraintLog requires delta'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Constraints - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- CPU: ${constraints.cpu || 'n/a'}`, + `- Memory: ${constraints.memory || 'n/a'}`, + '', + '**Evidence**', + `- Delta: ${JSON.stringify(delta.metrics || {})}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a hypotheses section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {Array} input.hypotheses + * @param {string} [input.date] + * @param {string} basePath + */ +function appendHypothesesLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendHypothesesLog requires an input object'); + } + const { id, userQuote, hypotheses, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendHypothesesLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendHypothesesLog requires a non-empty userQuote'); + } + if (!Array.isArray(hypotheses)) { + throw new Error('appendHypothesesLog requires hypotheses array'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const lines = hypotheses.map((item) => { + if (!item) return null; + const label = item.id ? `${item.id}: ` : ''; + const evidence = item.evidence ? ` (evidence: ${item.evidence})` : ''; + const confidence = item.confidence ? ` [${item.confidence}]` : ''; + return `- ${label}${item.hypothesis || 'n/a'}${confidence}${evidence}`; + }).filter(Boolean); + + const entry = [ + `## Hypotheses - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + lines.length > 0 ? lines.join('\n') : '- n/a', + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a code-paths section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string[]} input.keywords + * @param {Array} input.paths + * @param {string} [input.date] + * @param {string} basePath + */ +function appendCodePathsLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendCodePathsLog requires an input object'); + } + const { id, userQuote, keywords, paths, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendCodePathsLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendCodePathsLog requires a non-empty userQuote'); + } + if (!Array.isArray(paths)) { + throw new Error('appendCodePathsLog requires paths array'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const keywordText = Array.isArray(keywords) && keywords.length > 0 ? keywords.join(', ') : 'n/a'; + const pathLines = paths.map((pathEntry) => { + const file = pathEntry.file || 'n/a'; + const score = typeof pathEntry.score === 'number' ? ` (score: ${pathEntry.score})` : ''; + const symbols = Array.isArray(pathEntry.symbols) && pathEntry.symbols.length > 0 + ? ` [${pathEntry.symbols.join(', ')}]` + : ''; + return `- ${file}${score}${symbols}`; + }); + + const entry = [ + `## Code Paths - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Keywords: ${keywordText}`, + pathLines.length > 0 ? pathLines.join('\n') : '- n/a', + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append an optimization section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.change + * @param {object} input.delta + * @param {string} input.verdict + * @param {string} [input.date] + * @param {string} basePath + */ +function appendOptimizationLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendOptimizationLog requires an input object'); + } + const { id, userQuote, change, delta, verdict, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendOptimizationLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendOptimizationLog requires a non-empty userQuote'); + } + if (!change || typeof change !== 'string') { + throw new Error('appendOptimizationLog requires a change summary'); + } + if (!delta || typeof delta !== 'object') { + throw new Error('appendOptimizationLog requires delta'); + } + if (!verdict || typeof verdict !== 'string') { + throw new Error('appendOptimizationLog requires a verdict'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Optimization - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Change: ${change}`, + `- Verdict: ${verdict}`, + '', + '**Evidence**', + `- Delta: ${JSON.stringify(delta.metrics || {})}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a consolidation section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.version + * @param {string} input.path + * @param {string} [input.date] + * @param {string} basePath + */ +function appendConsolidationLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendConsolidationLog requires an input object'); + } + + const { id, userQuote, version, path, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendConsolidationLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendConsolidationLog requires a non-empty userQuote'); + } + if (!version || typeof version !== 'string') { + throw new Error('appendConsolidationLog requires a version'); + } + if (!path || typeof path !== 'string') { + throw new Error('appendConsolidationLog requires a path'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Consolidation - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Version: ${version}`, + `- Baseline file: ${path}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +module.exports = { + SCHEMA_VERSION, + PHASES, + generateInvestigationId, + getPerfDir, + ensurePerfDirs, + getInvestigationPath, + getInvestigationLogPath, + readInvestigation, + writeInvestigation, + updateInvestigation, + initializeInvestigation, + appendInvestigationLog, + appendBaselineLog, + appendProfilingLog, + appendDecisionLog, + appendSetupLog, + appendBreakingPointLog, + appendConstraintLog, + appendHypothesesLog, + appendCodePathsLog, + appendOptimizationLog, + appendConsolidationLog +}; diff --git a/plugins/enhance/lib/perf/optimization-runner.js b/plugins/enhance/lib/perf/optimization-runner.js new file mode 100644 index 00000000..4fb6ca0d --- /dev/null +++ b/plugins/enhance/lib/perf/optimization-runner.js @@ -0,0 +1,67 @@ +/** + * Optimization runner for /perf experiments. + * + * @module lib/perf/optimization-runner + */ + +const { runBenchmark, parseMetrics, DEFAULT_MIN_DURATION } = require('./benchmark-runner'); +const { compareBaselines } = require('./baseline-comparator'); +const { isWorkingTreeClean } = require('./checkpoint'); + +/** + * Run a single optimization experiment with two benchmark runs. + * NOTE: This helper does not modify code; it assumes the change was applied externally. + * + * @param {object} options + * @param {string} options.command + * @param {string} options.changeSummary + * @param {object} [options.env] + * @returns {{ baseline: object, experiment: object, delta: object, verdict: string, change: string }} + */ +function runOptimizationExperiment(options) { + const { command, changeSummary, env } = options || {}; + + if (!command || typeof command !== 'string') { + throw new Error('command must be a non-empty string'); + } + if (!changeSummary || typeof changeSummary !== 'string') { + throw new Error('changeSummary must be a non-empty string'); + } + + const shouldCheckClean = options?.requireClean !== false; + if (shouldCheckClean && !isWorkingTreeClean()) { + throw new Error('working tree is dirty before experiment'); + } + + const baselineRun = runBenchmark(command, { duration: DEFAULT_MIN_DURATION, env }); + const baselineMetrics = parseMetrics(baselineRun.output); + if (!baselineMetrics.ok) { + throw new Error(`Baseline parse failed: ${baselineMetrics.error}`); + } + + // NOTE: Caller is responsible for applying the experiment change here. + // Warm up the system (caches/JIT) before capturing experiment metrics. + runBenchmark(command, { duration: DEFAULT_MIN_DURATION, env }); + const experimentRun = runBenchmark(command, { duration: DEFAULT_MIN_DURATION, env }); + const experimentMetrics = parseMetrics(experimentRun.output); + if (!experimentMetrics.ok) { + throw new Error(`Experiment parse failed: ${experimentMetrics.error}`); + } + + const delta = compareBaselines( + { metrics: baselineMetrics.metrics }, + { metrics: experimentMetrics.metrics } + ); + + return { + change: changeSummary, + baseline: { metrics: baselineMetrics.metrics }, + experiment: { metrics: experimentMetrics.metrics }, + delta, + verdict: 'inconclusive' + }; +} + +module.exports = { + runOptimizationExperiment +}; diff --git a/plugins/enhance/lib/perf/profilers/go.js b/plugins/enhance/lib/perf/profilers/go.js new file mode 100644 index 00000000..9616ae6e --- /dev/null +++ b/plugins/enhance/lib/perf/profilers/go.js @@ -0,0 +1,22 @@ +/** + * Go pprof helper. + * + * @module lib/perf/profilers/go + */ + +module.exports = { + id: 'pprof', + tool: 'pprof', + buildCommand(options = {}) { + const command = options.command || 'go test'; + const output = options.output || 'cpu.pprof'; + return `${command} -cpuprofile=${output}`; + }, + parseOutput() { + return { + tool: 'pprof', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/enhance/lib/perf/profilers/index.js b/plugins/enhance/lib/perf/profilers/index.js new file mode 100644 index 00000000..20e66af9 --- /dev/null +++ b/plugins/enhance/lib/perf/profilers/index.js @@ -0,0 +1,46 @@ +/** + * Profilers registry for /perf. + * + * @module lib/perf/profilers + */ + +const fs = require('fs'); +const path = require('path'); +const cliEnhancers = require('../../patterns/cli-enhancers'); +const nodeProfiler = require('./node'); +const pythonProfiler = require('./python'); +const goProfiler = require('./go'); +const rustProfiler = require('./rust'); +const javaProfiler = require('./java'); + +function hasJavaIndicators(repoPath) { + const indicators = ['pom.xml', 'build.gradle', 'build.gradle.kts']; + return indicators.some((file) => fs.existsSync(path.join(repoPath, file))); +} + +function selectProfiler(repoPath = process.cwd()) { + const languages = cliEnhancers.detectProjectLanguages(repoPath); + + if (hasJavaIndicators(repoPath)) return javaProfiler; + if (languages.includes('typescript') || languages.includes('javascript')) return nodeProfiler; + if (languages.includes('go')) return goProfiler; + if (languages.includes('python')) return pythonProfiler; + if (languages.includes('rust')) return rustProfiler; + + return nodeProfiler; +} + +function listAvailable() { + return [ + nodeProfiler.id, + javaProfiler.id, + pythonProfiler.id, + goProfiler.id, + rustProfiler.id + ]; +} + +module.exports = { + listAvailable, + selectProfiler +}; diff --git a/plugins/enhance/lib/perf/profilers/java.js b/plugins/enhance/lib/perf/profilers/java.js new file mode 100644 index 00000000..bb464130 --- /dev/null +++ b/plugins/enhance/lib/perf/profilers/java.js @@ -0,0 +1,23 @@ +/** + * Java JFR profiler helper. + * + * @module lib/perf/profilers/java + */ + +module.exports = { + id: 'jfr', + tool: 'jfr', + buildCommand(options = {}) { + const command = options.command || 'java'; + const output = options.output || 'profile.jfr'; + const duration = options.duration || '60s'; + return `${command} -XX:StartFlightRecording=duration=${duration},filename=${output}`; + }, + parseOutput() { + return { + tool: 'jfr', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/enhance/lib/perf/profilers/node.js b/plugins/enhance/lib/perf/profilers/node.js new file mode 100644 index 00000000..95b7a857 --- /dev/null +++ b/plugins/enhance/lib/perf/profilers/node.js @@ -0,0 +1,22 @@ +/** + * Node.js profiler helper. + * + * @module lib/perf/profilers/node + */ + +module.exports = { + id: 'node', + tool: '--cpu-prof', + buildCommand(options = {}) { + const command = options.command || 'node'; + const output = options.output || 'node.cpuprofile'; + return `${command} --cpu-prof --cpu-prof-name=${output}`; + }, + parseOutput() { + return { + tool: 'node', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/enhance/lib/perf/profilers/python.js b/plugins/enhance/lib/perf/profilers/python.js new file mode 100644 index 00000000..98ede075 --- /dev/null +++ b/plugins/enhance/lib/perf/profilers/python.js @@ -0,0 +1,23 @@ +/** + * Python cProfile helper. + * + * @module lib/perf/profilers/python + */ + +module.exports = { + id: 'cprofile', + tool: 'cProfile', + buildCommand(options = {}) { + const command = options.command || 'python'; + const target = options.target || '-m'; + const output = options.output || 'profile.prof'; + return `${command} -m cProfile -o ${output} ${target}`; + }, + parseOutput() { + return { + tool: 'cprofile', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/enhance/lib/perf/profilers/rust.js b/plugins/enhance/lib/perf/profilers/rust.js new file mode 100644 index 00000000..416186d6 --- /dev/null +++ b/plugins/enhance/lib/perf/profilers/rust.js @@ -0,0 +1,23 @@ +/** + * Rust perf helper (Linux). + * + * @module lib/perf/profilers/rust + */ + +module.exports = { + id: 'perf', + tool: 'perf', + buildCommand(options = {}) { + const command = options.command || 'perf record'; + const output = options.output || 'perf.data'; + const target = options.target || './target/release/app'; + return `${command} -o ${output} ${target}`; + }, + parseOutput() { + return { + tool: 'perf', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/enhance/lib/perf/profiling-runner.js b/plugins/enhance/lib/perf/profiling-runner.js new file mode 100644 index 00000000..e1204f25 --- /dev/null +++ b/plugins/enhance/lib/perf/profiling-runner.js @@ -0,0 +1,48 @@ +/** + * Profiling execution helper. + * + * @module lib/perf/profiling-runner + */ + +const { execSync } = require('child_process'); +const profilers = require('./profilers'); + +/** + * Run a profiling command and return artifacts/hotspots metadata. + * @param {object} options + * @param {string} [options.repoPath] + * @param {object} [options.profileOptions] + * @returns {{ ok: boolean, result?: object, error?: string }} + */ +function runProfiling(options = {}) { + const repoPath = options.repoPath || process.cwd(); + const profiler = profilers.selectProfiler(repoPath); + + if (!profiler || typeof profiler.buildCommand !== 'function') { + return { ok: false, error: 'No profiler available' }; + } + + const command = profiler.buildCommand(options.profileOptions || {}); + try { + execSync(command, { stdio: 'pipe' }); + } catch (error) { + return { ok: false, error: error.message }; + } + + const parsed = typeof profiler.parseOutput === 'function' + ? profiler.parseOutput() + : { tool: profiler.id, hotspots: [], artifacts: [] }; + + const result = { + tool: profiler.id, + command, + hotspots: parsed.hotspots || [], + artifacts: parsed.artifacts || [] + }; + + return { ok: true, result }; +} + +module.exports = { + runProfiling +}; diff --git a/plugins/enhance/lib/perf/schemas.js b/plugins/enhance/lib/perf/schemas.js new file mode 100644 index 00000000..8b86761e --- /dev/null +++ b/plugins/enhance/lib/perf/schemas.js @@ -0,0 +1,140 @@ +/** + * Schema validation helpers for /perf. + * + * @module lib/perf/schemas + */ + +const REQUIRED_INVESTIGATION_FIELDS = ['schemaVersion', 'id', 'status', 'phase', 'scenario']; +const REQUIRED_BASELINE_FIELDS = ['version', 'recordedAt', 'metrics', 'command']; + +function isObject(value) { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function validateInvestigationState(state) { + const errors = []; + + if (!isObject(state)) { + return { ok: false, errors: ['state must be an object'] }; + } + + for (const field of REQUIRED_INVESTIGATION_FIELDS) { + if (!Object.prototype.hasOwnProperty.call(state, field)) { + errors.push(`missing ${field}`); + } + } + + if (typeof state.id !== 'string' || state.id.trim().length === 0) { + errors.push('id must be a non-empty string'); + } + + if (typeof state.phase !== 'string' || state.phase.trim().length === 0) { + errors.push('phase must be a non-empty string'); + } + + if (!isObject(state.scenario)) { + errors.push('scenario must be an object'); + } else { + if (typeof state.scenario.description !== 'string') { + errors.push('scenario.description must be a string'); + } + if (!Array.isArray(state.scenario.metrics)) { + errors.push('scenario.metrics must be an array'); + } + if (typeof state.scenario.successCriteria !== 'string') { + errors.push('scenario.successCriteria must be a string'); + } + if (state.scenario.scenarios != null) { + if (!Array.isArray(state.scenario.scenarios)) { + errors.push('scenario.scenarios must be an array when provided'); + } else { + state.scenario.scenarios.forEach((scenario, index) => { + if (!isObject(scenario)) { + errors.push(`scenario.scenarios[${index}] must be an object`); + return; + } + if (typeof scenario.name !== 'string' || scenario.name.trim().length === 0) { + errors.push(`scenario.scenarios[${index}].name must be a non-empty string`); + } + if (scenario.params != null && !isObject(scenario.params)) { + errors.push(`scenario.scenarios[${index}].params must be an object when provided`); + } + }); + } + } + } + + return { ok: errors.length === 0, errors }; +} + +function validateBaseline(baseline) { + const errors = []; + + if (!isObject(baseline)) { + return { ok: false, errors: ['baseline must be an object'] }; + } + + for (const field of REQUIRED_BASELINE_FIELDS) { + if (!Object.prototype.hasOwnProperty.call(baseline, field)) { + errors.push(`missing ${field}`); + } + } + + if (typeof baseline.version !== 'string' || baseline.version.trim().length === 0) { + errors.push('version must be a non-empty string'); + } + + if (typeof baseline.recordedAt !== 'string' || baseline.recordedAt.trim().length === 0) { + errors.push('recordedAt must be an ISO8601 string'); + } + + if (typeof baseline.command !== 'string' || baseline.command.trim().length === 0) { + errors.push('command must be a non-empty string'); + } + + if (!isObject(baseline.metrics)) { + errors.push('metrics must be an object'); + } else { + if (baseline.metrics.scenarios != null) { + if (!isObject(baseline.metrics.scenarios)) { + errors.push('metrics.scenarios must be an object when provided'); + } else { + for (const [scenarioName, scenarioMetrics] of Object.entries(baseline.metrics.scenarios)) { + if (!isObject(scenarioMetrics)) { + errors.push(`metrics.scenarios.${scenarioName} must be an object`); + continue; + } + for (const [key, value] of Object.entries(scenarioMetrics)) { + if (typeof value !== 'number' || Number.isNaN(value)) { + errors.push(`metric ${scenarioName}.${key} must be a number`); + } + } + } + } + } else { + for (const [key, value] of Object.entries(baseline.metrics)) { + if (typeof value !== 'number' || Number.isNaN(value)) { + errors.push(`metric ${key} must be a number`); + } + } + } + } + + if (baseline.env && !isObject(baseline.env)) { + errors.push('env must be an object when provided'); + } + + return { ok: errors.length === 0, errors }; +} + +function assertValid(result, message) { + if (!result.ok) { + throw new Error(`${message}: ${result.errors.join(', ')}`); + } +} + +module.exports = { + validateInvestigationState, + validateBaseline, + assertValid +}; diff --git a/plugins/enhance/skills/agent-prompts/SKILL.md b/plugins/enhance/skills/agent-prompts/SKILL.md new file mode 100644 index 00000000..ee4f0163 --- /dev/null +++ b/plugins/enhance/skills/agent-prompts/SKILL.md @@ -0,0 +1,31 @@ +--- +name: enhance-agent-prompts +description: "Use when improving agent prompts, frontmatter, and tool restrictions." +version: 1.0.0 +--- + +# enhance-agent-prompts + +Analyze agent prompts for structure, tool restrictions, and clarity. + +## Required Checks + +- Frontmatter: name, description, tools, model. +- Tool permissions are least-privilege. +- Instructions are explicit and structured. + +## Best-Practices Context + +- Use XML tags for complex prompts. +- Avoid redundant step-by-step instructions for thinking models. +- Place critical constraints at the top. + +## Output Format + +``` +summary: +findings: + - file: + issue: + fix: +``` diff --git a/plugins/enhance/skills/claude-memory/SKILL.md b/plugins/enhance/skills/claude-memory/SKILL.md new file mode 100644 index 00000000..40a50bca --- /dev/null +++ b/plugins/enhance/skills/claude-memory/SKILL.md @@ -0,0 +1,31 @@ +--- +name: enhance-claude-memory +description: "Use when improving CLAUDE.md or AGENTS.md project memory files." +version: 1.0.0 +--- + +# enhance-claude-memory + +Improve CLAUDE.md / AGENTS.md for clarity and correctness. + +## Required Checks + +- Architecture, testing, and workflow conventions are present. +- Tool permissions and safety rules are explicit. +- No secrets or environment-specific paths. +- Platform notes: Claude, OpenCode, Codex behavior is accurate. + +## Best-Practices Context + +- Keep memory concise and actionable. +- Use short bullet lists for constraints. +- Include repo-specific commands and conventions. + +## Output Format + +``` +summary: +changes: + - file: + update: +``` diff --git a/plugins/enhance/skills/docs/SKILL.md b/plugins/enhance/skills/docs/SKILL.md new file mode 100644 index 00000000..8f46cece --- /dev/null +++ b/plugins/enhance/skills/docs/SKILL.md @@ -0,0 +1,30 @@ +--- +name: enhance-docs +description: "Use when improving documentation structure, accuracy, and RAG readiness." +version: 1.0.0 +--- + +# enhance-docs + +Analyze docs for structure, accuracy, and retrieval readiness. + +## Required Checks + +- Broken internal links and missing sections. +- Outdated references vs code. +- Clear examples and quick-start guidance. + +## Best-Practices Context + +- Use clear headings and consistent anchors. +- Keep docs concise; prefer tables for structured info. +- Include minimal, verified examples. + +## Output Format + +``` +summary: +findings: + - file: + issue: +``` diff --git a/plugins/enhance/skills/hooks/SKILL.md b/plugins/enhance/skills/hooks/SKILL.md new file mode 100644 index 00000000..4880d1b5 --- /dev/null +++ b/plugins/enhance/skills/hooks/SKILL.md @@ -0,0 +1,31 @@ +--- +name: enhance-hooks +description: "Use when reviewing hooks for safety, timeouts, and correct frontmatter." +version: 1.0.0 +--- + +# enhance-hooks + +Analyze hook definitions and hook scripts for safety and correctness. + +## Required Checks + +- Hooks have frontmatter name/description. +- Dangerous commands are guarded or blocked. +- Timeouts are configured for command hooks. + +## Best-Practices Context + +- Hooks should fail fast (`set -euo pipefail` in scripts). +- Avoid destructive commands or require explicit permission. +- Keep hook outputs short and actionable. + +## Output Format + +``` +summary: +findings: + - file: + issue: + fix: +``` diff --git a/plugins/enhance/skills/orchestrator/SKILL.md b/plugins/enhance/skills/orchestrator/SKILL.md new file mode 100644 index 00000000..e11e61cd --- /dev/null +++ b/plugins/enhance/skills/orchestrator/SKILL.md @@ -0,0 +1,32 @@ +--- +name: enhance-orchestrator +description: "Use when coordinating multiple enhancers and producing a unified /enhance report." +version: 1.0.0 +--- + +# enhance-orchestrator + +Coordinate all enhancement analyzers and produce a unified report. + +## Required Behavior + +- Run relevant enhancers in parallel. +- Respect --focus flags and target-path scoping. +- Deduplicate findings across enhancers. +- Apply auto-fixes only when explicitly requested. + +## Best-Practices Context + +- Use parallel subagents when tasks are independent. +- Keep outputs concise and evidence-backed. +- Ensure findings are actionable and reference file paths. + +## Output Format + +``` +summary: +by_enhancer: + - : { high: n, medium: n, low: n } +next_steps: + - +``` diff --git a/plugins/enhance/skills/plugins/SKILL.md b/plugins/enhance/skills/plugins/SKILL.md new file mode 100644 index 00000000..57548484 --- /dev/null +++ b/plugins/enhance/skills/plugins/SKILL.md @@ -0,0 +1,31 @@ +--- +name: enhance-plugins +description: "Use when analyzing plugin structures, MCP tools, and plugin security patterns." +version: 1.0.0 +--- + +# enhance-plugins + +Analyze plugin structures, MCP tools, and security patterns. + +## Required Checks + +- plugin.json validity (versions, required fields). +- MCP tool schemas: required fields, additionalProperties false, clear descriptions. +- Security patterns (unrestricted bash, hardcoded secrets). + +## Best-Practices Context + +- Prefer strict schemas with enums and required fields. +- Tool descriptions must include when-to-use guidance. +- Avoid over-privileged tool permissions. + +## Output Format + +``` +summary: +findings: + - file: + issue: + certainty: HIGH|MEDIUM|LOW +``` diff --git a/plugins/enhance/skills/prompts/SKILL.md b/plugins/enhance/skills/prompts/SKILL.md new file mode 100644 index 00000000..cfb132c9 --- /dev/null +++ b/plugins/enhance/skills/prompts/SKILL.md @@ -0,0 +1,30 @@ +--- +name: enhance-prompts +description: "Use when improving general prompts for structure, examples, and constraints." +version: 1.0.0 +--- + +# enhance-prompts + +Analyze prompts for clarity, structure, and output reliability. + +## Required Checks + +- Explicit constraints and output format. +- Examples for complex tasks. +- Avoid negative-only rules. + +## Best-Practices Context + +- Use XML tags for complex instructions. +- Include JSON schema when requesting structured output. +- Keep critical info at the start/end. + +## Output Format + +``` +summary: +findings: + - file: + issue: +``` diff --git a/plugins/enhance/skills/reporter/SKILL.md b/plugins/enhance/skills/reporter/SKILL.md new file mode 100644 index 00000000..356d4d2d --- /dev/null +++ b/plugins/enhance/skills/reporter/SKILL.md @@ -0,0 +1,22 @@ +--- +name: enhance-reporter +description: "Use when generating the unified enhancement report." +version: 1.0.0 +--- + +# enhance-reporter + +Generate the unified enhancement report from all enhancer findings. + +## Required Behavior + +- Group findings by enhancer. +- Deduplicate identical issues. +- Prioritize HIGH, then MEDIUM, then LOW (if verbose). + +## Output Format + +``` +report: | + +``` diff --git a/plugins/enhance/skills/skills/SKILL.md b/plugins/enhance/skills/skills/SKILL.md new file mode 100644 index 00000000..8a433434 --- /dev/null +++ b/plugins/enhance/skills/skills/SKILL.md @@ -0,0 +1,30 @@ +--- +name: enhance-skills +description: "Use when reviewing SKILL.md files for structure and trigger quality." +version: 1.0.0 +--- + +# enhance-skills + +Analyze SKILL.md files for frontmatter correctness and trigger quality. + +## Required Checks + +- Frontmatter includes name + description. +- Description includes clear trigger phrases ("Use when user asks…"). +- Skill content is concise and scoped. + +## Best-Practices Context + +- Keep SKILL.md under ~500 lines; move extras to references/. +- Provide explicit triggers and outcomes. +- Avoid ambiguous descriptions. + +## Output Format + +``` +summary: +findings: + - file: + issue: +``` diff --git a/plugins/next-task/lib/enhance/hook-analyzer.js b/plugins/next-task/lib/enhance/hook-analyzer.js new file mode 100644 index 00000000..2530e111 --- /dev/null +++ b/plugins/next-task/lib/enhance/hook-analyzer.js @@ -0,0 +1,135 @@ +/** + * Hook analyzer for /enhance. + */ + +const fs = require('fs'); +const path = require('path'); +const { hookPatterns } = require('./hook-patterns'); +const { parseMarkdownFrontmatter } = require('./agent-analyzer'); + +function analyzeHook(hookPath) { + const results = { + hookName: path.basename(hookPath, '.md'), + hookPath, + structureIssues: [] + }; + + if (!fs.existsSync(hookPath)) { + results.structureIssues.push({ + issue: 'File not found', + file: hookPath, + certainty: 'HIGH', + patternId: 'file_not_found' + }); + return results; + } + + let content = ''; + try { + content = fs.readFileSync(hookPath, 'utf8'); + } catch (err) { + results.structureIssues.push({ + issue: `Failed to read file: ${err.message}`, + file: hookPath, + certainty: 'HIGH', + patternId: 'read_error' + }); + return results; + } + + const missingFm = hookPatterns.missing_frontmatter.check(content); + if (missingFm) { + results.structureIssues.push({ + ...missingFm, + file: hookPath, + certainty: hookPatterns.missing_frontmatter.certainty, + patternId: hookPatterns.missing_frontmatter.id + }); + } + + const { frontmatter } = parseMarkdownFrontmatter(content); + const missingName = hookPatterns.missing_name.check(frontmatter); + if (missingName) { + results.structureIssues.push({ + ...missingName, + file: hookPath, + certainty: hookPatterns.missing_name.certainty, + patternId: hookPatterns.missing_name.id + }); + } + + const missingDescription = hookPatterns.missing_description.check(frontmatter); + if (missingDescription) { + results.structureIssues.push({ + ...missingDescription, + file: hookPath, + certainty: hookPatterns.missing_description.certainty, + patternId: hookPatterns.missing_description.id + }); + } + + return results; +} + +function analyzeAllHooks(hooksDir) { + const results = []; + if (!fs.existsSync(hooksDir)) return results; + + const hookFiles = []; + const skipDirs = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'target']); + + function walk(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (err) { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!skipDirs.has(entry.name)) { + walk(fullPath); + } + continue; + } + + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + const parts = fullPath.split(path.sep); + if (parts.includes('hooks')) { + hookFiles.push(fullPath); + } + } + } + + walk(hooksDir); + + for (const file of hookFiles) { + results.push(analyzeHook(file)); + } + + return results; +} + +function analyze(options = {}) { + const { + hook, + hooksDir = 'plugins/enhance/hooks' + } = options; + + if (hook) { + const hookPath = hook.endsWith('.md') + ? hook + : path.join(hooksDir, `${hook}.md`); + return analyzeHook(hookPath); + } + + return analyzeAllHooks(hooksDir); +} + +module.exports = { + analyzeHook, + analyzeAllHooks, + analyze +}; diff --git a/plugins/next-task/lib/enhance/hook-patterns.js b/plugins/next-task/lib/enhance/hook-patterns.js new file mode 100644 index 00000000..472c789b --- /dev/null +++ b/plugins/next-task/lib/enhance/hook-patterns.js @@ -0,0 +1,40 @@ +/** + * Hook patterns for /enhance. + */ + +const hookPatterns = { + missing_frontmatter: { + id: 'missing_frontmatter', + certainty: 'HIGH', + check(content) { + if (!content || !content.trim().startsWith('---')) { + return { issue: 'Missing YAML frontmatter in hook file' }; + } + return null; + } + }, + missing_name: { + id: 'missing_name', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.name) { + return { issue: 'Missing name in hook frontmatter' }; + } + return null; + } + }, + missing_description: { + id: 'missing_description', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.description) { + return { issue: 'Missing description in hook frontmatter' }; + } + return null; + } + } +}; + +module.exports = { + hookPatterns +}; diff --git a/plugins/next-task/lib/enhance/index.js b/plugins/next-task/lib/enhance/index.js index 542e81fd..07539241 100644 --- a/plugins/next-task/lib/enhance/index.js +++ b/plugins/next-task/lib/enhance/index.js @@ -16,6 +16,8 @@ const projectmemoryAnalyzer = require('./projectmemory-analyzer'); const projectmemoryPatterns = require('./projectmemory-patterns'); const promptAnalyzer = require('./prompt-analyzer'); const promptPatterns = require('./prompt-patterns'); +const hookAnalyzer = require('./hook-analyzer'); +const skillAnalyzer = require('./skill-analyzer'); const reporter = require('./reporter'); const fixer = require('./fixer'); @@ -26,6 +28,8 @@ module.exports = { docsAnalyzer, projectmemoryAnalyzer, promptAnalyzer, + hookAnalyzer, + skillAnalyzer, // Pattern modules pluginPatterns, @@ -72,6 +76,16 @@ module.exports = { promptApplyFixes: promptAnalyzer.applyFixes, promptGenerateReport: promptAnalyzer.generateReport, + // Convenience exports - Hooks + analyzeHook: hookAnalyzer.analyzeHook, + analyzeAllHooks: hookAnalyzer.analyzeAllHooks, + hooksAnalyze: hookAnalyzer.analyze, + + // Convenience exports - Skills + analyzeSkill: skillAnalyzer.analyzeSkill, + analyzeAllSkills: skillAnalyzer.analyzeAllSkills, + skillsAnalyze: skillAnalyzer.analyze, + // Convenience exports - Orchestrator generateOrchestratorReport: reporter.generateOrchestratorReport, deduplicateOrchestratorFindings: reporter.deduplicateOrchestratorFindings diff --git a/plugins/next-task/lib/enhance/reporter.js b/plugins/next-task/lib/enhance/reporter.js index 7016a1f8..77b727c6 100644 --- a/plugins/next-task/lib/enhance/reporter.js +++ b/plugins/next-task/lib/enhance/reporter.js @@ -1091,7 +1091,7 @@ function generateOrchestratorReport(aggregatedResults, options = {}) { lines.push('| Enhancer | HIGH | MEDIUM | LOW | Auto-Fixable |'); lines.push('|----------|------|--------|-----|--------------|'); - const enhancerTypes = ['plugin', 'agent', 'claudemd', 'docs', 'prompt']; + const enhancerTypes = ['plugin', 'agent', 'claudemd', 'docs', 'prompt', 'hooks', 'skills']; let totalHigh = 0, totalMedium = 0, totalLow = 0, totalAutoFix = 0; for (const enhancer of enhancerTypes) { diff --git a/plugins/next-task/lib/enhance/skill-analyzer.js b/plugins/next-task/lib/enhance/skill-analyzer.js new file mode 100644 index 00000000..023ac494 --- /dev/null +++ b/plugins/next-task/lib/enhance/skill-analyzer.js @@ -0,0 +1,144 @@ +/** + * Skill analyzer for /enhance. + */ + +const fs = require('fs'); +const path = require('path'); +const { skillPatterns } = require('./skill-patterns'); +const { parseMarkdownFrontmatter } = require('./agent-analyzer'); + +function analyzeSkill(skillPath) { + const results = { + skillName: path.basename(path.dirname(skillPath)), + skillPath, + structureIssues: [], + triggerIssues: [] + }; + + if (!fs.existsSync(skillPath)) { + results.structureIssues.push({ + issue: 'File not found', + file: skillPath, + certainty: 'HIGH', + patternId: 'file_not_found' + }); + return results; + } + + let content = ''; + try { + content = fs.readFileSync(skillPath, 'utf8'); + } catch (err) { + results.structureIssues.push({ + issue: `Failed to read file: ${err.message}`, + file: skillPath, + certainty: 'HIGH', + patternId: 'read_error' + }); + return results; + } + + const missingFm = skillPatterns.missing_frontmatter.check(content); + if (missingFm) { + results.structureIssues.push({ + ...missingFm, + file: skillPath, + certainty: skillPatterns.missing_frontmatter.certainty, + patternId: skillPatterns.missing_frontmatter.id + }); + } + + const { frontmatter } = parseMarkdownFrontmatter(content); + const missingName = skillPatterns.missing_name.check(frontmatter); + if (missingName) { + results.structureIssues.push({ + ...missingName, + file: skillPath, + certainty: skillPatterns.missing_name.certainty, + patternId: skillPatterns.missing_name.id + }); + } + + const missingDescription = skillPatterns.missing_description.check(frontmatter); + if (missingDescription) { + results.structureIssues.push({ + ...missingDescription, + file: skillPath, + certainty: skillPatterns.missing_description.certainty, + patternId: skillPatterns.missing_description.id + }); + } + + const missingTrigger = skillPatterns.missing_trigger_phrase.check(frontmatter); + if (missingTrigger) { + results.triggerIssues.push({ + ...missingTrigger, + file: skillPath, + certainty: skillPatterns.missing_trigger_phrase.certainty, + patternId: skillPatterns.missing_trigger_phrase.id + }); + } + + return results; +} + +function analyzeAllSkills(skillsDir) { + const results = []; + if (!fs.existsSync(skillsDir)) return results; + + const skillFiles = []; + const skipDirs = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'target']); + + function walk(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (err) { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!skipDirs.has(entry.name)) { + walk(fullPath); + } + continue; + } + + if (entry.isFile() && entry.name === 'SKILL.md') { + skillFiles.push(fullPath); + } + } + } + + walk(skillsDir); + + for (const skillPath of skillFiles) { + results.push(analyzeSkill(skillPath)); + } + + return results; +} + +function analyze(options = {}) { + const { + skill, + skillsDir = 'plugins/enhance/skills' + } = options; + + if (skill) { + const skillPath = skill.endsWith('SKILL.md') + ? skill + : path.join(skillsDir, skill, 'SKILL.md'); + return analyzeSkill(skillPath); + } + + return analyzeAllSkills(skillsDir); +} + +module.exports = { + analyzeSkill, + analyzeAllSkills, + analyze +}; diff --git a/plugins/next-task/lib/enhance/skill-patterns.js b/plugins/next-task/lib/enhance/skill-patterns.js new file mode 100644 index 00000000..50872c58 --- /dev/null +++ b/plugins/next-task/lib/enhance/skill-patterns.js @@ -0,0 +1,51 @@ +/** + * Skill patterns for /enhance. + */ + +const skillPatterns = { + missing_frontmatter: { + id: 'missing_frontmatter', + certainty: 'HIGH', + check(content) { + if (!content || !content.trim().startsWith('---')) { + return { issue: 'Missing YAML frontmatter in SKILL.md' }; + } + return null; + } + }, + missing_name: { + id: 'missing_name', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.name) { + return { issue: 'Missing name in SKILL.md frontmatter' }; + } + return null; + } + }, + missing_description: { + id: 'missing_description', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.description) { + return { issue: 'Missing description in SKILL.md frontmatter' }; + } + return null; + } + }, + missing_trigger_phrase: { + id: 'missing_trigger_phrase', + certainty: 'MEDIUM', + check(frontmatter) { + if (!frontmatter || !frontmatter.description) return null; + if (!/use when user asks/i.test(frontmatter.description)) { + return { issue: 'Description missing "Use when user asks" trigger phrase' }; + } + return null; + } + } +}; + +module.exports = { + skillPatterns +}; diff --git a/plugins/next-task/lib/index.js b/plugins/next-task/lib/index.js index 646eb350..07706b6c 100644 --- a/plugins/next-task/lib/index.js +++ b/plugins/next-task/lib/index.js @@ -26,6 +26,7 @@ const policyQuestions = require('./sources/policy-questions'); const crossPlatform = require('./cross-platform'); const enhance = require('./enhance'); const repoMap = require('./repo-map'); +const perf = require('./perf'); /** * Platform detection and verification utilities @@ -228,6 +229,7 @@ module.exports = { xplat, enhance, repoMap, + perf, // Direct module access for backward compatibility detectPlatform, diff --git a/plugins/next-task/lib/perf/analyzer/index.js b/plugins/next-task/lib/perf/analyzer/index.js new file mode 100644 index 00000000..87fd5c4f --- /dev/null +++ b/plugins/next-task/lib/perf/analyzer/index.js @@ -0,0 +1,22 @@ +/** + * Perf analysis helpers. + * + * @module lib/perf/analyzer + */ + +/** + * Build a compact summary of perf findings. + * @param {object} input + * @returns {object} + */ +function summarize(input = {}) { + return { + summary: input.summary || '', + recommendations: input.recommendations || [], + risks: input.risks || [] + }; +} + +module.exports = { + summarize +}; diff --git a/plugins/next-task/lib/perf/argument-parser.js b/plugins/next-task/lib/perf/argument-parser.js new file mode 100644 index 00000000..46b04d35 --- /dev/null +++ b/plugins/next-task/lib/perf/argument-parser.js @@ -0,0 +1,65 @@ +/** + * Argument parsing helper for /perf. + * + * @module lib/perf/argument-parser + */ + +function parseArguments(raw) { + if (!raw || typeof raw !== 'string') return []; + + const args = []; + let current = ''; + let quote = null; + let escaped = false; + + for (let i = 0; i < raw.length; i++) { + const ch = raw[i]; + + if (escaped) { + current += ch; + escaped = false; + continue; + } + + if (ch === '\\') { + if (quote) { + escaped = true; + continue; + } + } + + if (quote) { + if (ch === quote) { + quote = null; + } else { + current += ch; + } + continue; + } + + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + + if (/\s/.test(ch)) { + if (current) { + args.push(current); + current = ''; + } + continue; + } + + current += ch; + } + + if (current) { + args.push(current); + } + + return args; +} + +module.exports = { + parseArguments +}; diff --git a/plugins/next-task/lib/perf/baseline-comparator.js b/plugins/next-task/lib/perf/baseline-comparator.js new file mode 100644 index 00000000..7e71220a --- /dev/null +++ b/plugins/next-task/lib/perf/baseline-comparator.js @@ -0,0 +1,50 @@ +/** + * Baseline comparison helpers + * + * @module lib/perf/baseline-comparator + */ + +/** + * Compute delta between baseline and current metrics. + * Supports flat numeric values under baseline.metrics/current.metrics. + * + * @param {object} baseline + * @param {object} current + * @returns {object} + */ +function compareBaselines(baseline, current) { + const baselineMetrics = baseline?.metrics || {}; + const currentMetrics = current?.metrics || {}; + const keys = new Set([ + ...Object.keys(baselineMetrics), + ...Object.keys(currentMetrics) + ]); + + const deltas = {}; + for (const key of keys) { + const baseValue = baselineMetrics[key]; + const currentValue = currentMetrics[key]; + + if (typeof baseValue === 'number' && typeof currentValue === 'number') { + const delta = currentValue - baseValue; + const percent = baseValue === 0 ? null : delta / baseValue; + deltas[key] = { baseline: baseValue, current: currentValue, delta, percent }; + } else { + deltas[key] = { + baseline: baseValue ?? null, + current: currentValue ?? null, + delta: null, + percent: null + }; + } + } + + return { + comparedAt: new Date().toISOString(), + metrics: deltas + }; +} + +module.exports = { + compareBaselines +}; diff --git a/plugins/next-task/lib/perf/baseline-store.js b/plugins/next-task/lib/perf/baseline-store.js new file mode 100644 index 00000000..f8c8a21f --- /dev/null +++ b/plugins/next-task/lib/perf/baseline-store.js @@ -0,0 +1,127 @@ +/** + * Baseline storage utilities for /perf + * + * Stores baselines under: + * - {state-dir}/perf/baselines/{version}.json + * + * @module lib/perf/baseline-store + */ + +const fs = require('fs'); +const path = require('path'); +const { getStateDir } = require('../platform/state-dir'); +const { validateBaseline, assertValid } = require('./schemas'); + +const BASELINE_DIR = 'baselines'; + +function assertSafeBaselineVersion(version) { + if (!version || typeof version !== 'string') { + throw new Error('Baseline version is required'); + } + if (version.includes('..') || version.includes('/') || version.includes('\\') || version.includes('\0')) { + throw new Error('Baseline version contains invalid characters'); + } + if (!/^[a-zA-Z0-9._+-]+$/.test(version)) { + throw new Error('Baseline version contains invalid characters'); + } + return version; +} + +/** + * Get baseline directory path + * @param {string} basePath + * @returns {string} + */ +function getBaselineDir(basePath = process.cwd()) { + return path.join(basePath, getStateDir(basePath), 'perf', BASELINE_DIR); +} + +/** + * Ensure baseline directory exists + * @param {string} basePath + * @returns {string} + */ +function ensureBaselineDir(basePath = process.cwd()) { + const dir = getBaselineDir(basePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + return dir; +} + +/** + * Build baseline file path + * @param {string} version + * @param {string} basePath + * @returns {string} + */ +function getBaselinePath(version, basePath = process.cwd()) { + const safeVersion = assertSafeBaselineVersion(version); + return path.join(ensureBaselineDir(basePath), `${safeVersion}.json`); +} + +/** + * List baseline versions + * @param {string} basePath + * @returns {string[]} + */ +function listBaselines(basePath = process.cwd()) { + const dir = ensureBaselineDir(basePath); + return fs.readdirSync(dir) + .filter(file => file.endsWith('.json')) + .map(file => path.basename(file, '.json')) + .sort(); +} + +/** + * Read baseline file + * @param {string} version + * @param {string} basePath + * @returns {object|null} + */ +function readBaseline(version, basePath = process.cwd()) { + const baselinePath = getBaselinePath(version, basePath); + if (!fs.existsSync(baselinePath)) { + return null; + } + try { + const parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf8')); + const validation = validateBaseline(parsed); + if (!validation.ok) { + console.error(`[CRITICAL] Invalid baseline file at ${baselinePath}: ${validation.errors.join(', ')}`); + return null; + } + return parsed; + } catch (error) { + console.error(`[CRITICAL] Corrupted baseline file at ${baselinePath}: ${error.message}`); + return null; + } +} + +/** + * Write baseline file (overwrites existing) + * @param {string} version + * @param {object} baseline + * @param {string} basePath + * @returns {boolean} + */ +function writeBaseline(version, baseline, basePath = process.cwd()) { + const baselinePath = getBaselinePath(version, basePath); + const payload = { + version, + recordedAt: new Date().toISOString(), + ...baseline + }; + assertValid(validateBaseline(payload), 'Invalid baseline payload'); + fs.writeFileSync(baselinePath, JSON.stringify(payload, null, 2), 'utf8'); + return true; +} + +module.exports = { + getBaselineDir, + ensureBaselineDir, + getBaselinePath, + listBaselines, + readBaseline, + writeBaseline +}; diff --git a/plugins/next-task/lib/perf/benchmark-runner.js b/plugins/next-task/lib/perf/benchmark-runner.js new file mode 100644 index 00000000..c245815c --- /dev/null +++ b/plugins/next-task/lib/perf/benchmark-runner.js @@ -0,0 +1,107 @@ +/** + * Sequential benchmark runner utilities. + * + * @module lib/perf/benchmark-runner + */ + +const { execSync } = require('child_process'); +const { validateBaseline } = require('./schemas'); + +const DEFAULT_MIN_DURATION = 60; +const BINARY_SEARCH_MIN_DURATION = 30; + +/** + * Normalize benchmark options and enforce minimum durations. + * @param {object} options + * @returns {object} + */ +function normalizeBenchmarkOptions(options = {}) { + const mode = options.mode || 'full'; + const minDuration = mode === 'binary-search' + ? BINARY_SEARCH_MIN_DURATION + : DEFAULT_MIN_DURATION; + + const duration = Math.max(options.duration || minDuration, minDuration); + return { + ...options, + mode, + duration, + warmup: options.warmup || 10 + }; +} + +/** + * Run a benchmark command synchronously (sequential only). + * @param {string} command + * @param {object} options + * @returns {{ success: boolean, output: string }} + */ +function runBenchmark(command, options = {}) { + if (!command || typeof command !== 'string') { + throw new Error('Benchmark command must be a non-empty string'); + } + + const normalized = normalizeBenchmarkOptions(options); + const env = { ...process.env, ...normalized.env }; + + const output = execSync(command, { + stdio: 'pipe', + encoding: 'utf8', + env + }); + + return { + success: true, + output, + duration: normalized.duration, + warmup: normalized.warmup, + mode: normalized.mode + }; +} + +/** + * Parse metrics from benchmark output using PERF_METRICS markers. + * @param {string} output + * @returns {{ ok: boolean, metrics?: object, error?: string }} + */ +function parseMetrics(output) { + if (typeof output !== 'string') { + return { ok: false, error: 'Output must be a string' }; + } + + const startMarker = 'PERF_METRICS_START'; + const endMarker = 'PERF_METRICS_END'; + const startIndex = output.indexOf(startMarker); + const endIndex = output.indexOf(endMarker); + + if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) { + return { ok: false, error: 'Metrics markers not found' }; + } + + const jsonStart = startIndex + startMarker.length; + const raw = output.slice(jsonStart, endIndex).trim(); + + try { + const parsed = JSON.parse(raw); + const validation = validateBaseline({ + version: 'temp', + recordedAt: new Date().toISOString(), + command: 'temp', + metrics: parsed + }); + if (!validation.ok) { + return { ok: false, error: `Invalid metrics: ${validation.errors.join(', ')}` }; + } + return { ok: true, metrics: parsed }; + } catch (error) { + return { ok: false, error: `Failed to parse metrics JSON: ${error.message}` }; + } +} + +module.exports = { + DEFAULT_MIN_DURATION, + BINARY_SEARCH_MIN_DURATION, + normalizeBenchmarkOptions, + runBenchmark, + parseMetrics +}; diff --git a/plugins/next-task/lib/perf/breaking-point-finder.js b/plugins/next-task/lib/perf/breaking-point-finder.js new file mode 100644 index 00000000..d7239cce --- /dev/null +++ b/plugins/next-task/lib/perf/breaking-point-finder.js @@ -0,0 +1,52 @@ +/** + * Binary search helper for breaking point discovery. + * + * @module lib/perf/breaking-point-finder + */ + +/** + * Find breaking point using binary search. + * The runner should return { ok: boolean, data?: any }. + * + * @param {object} options + * @param {number} options.min + * @param {number} options.max + * @param {(value:number)=>Promise<{ok:boolean,data?:any}>} options.runner + * @returns {Promise<{breakingPoint:number|null, attempts:number, history:Array}>} + */ +async function findBreakingPoint({ min, max, runner }) { + if (typeof min !== 'number' || typeof max !== 'number') { + throw new Error('min and max must be numbers'); + } + if (typeof runner !== 'function') { + throw new Error('runner must be a function'); + } + + let low = min; + let high = max; + let breakingPoint = null; + const history = []; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const result = await runner(mid); + history.push({ value: mid, ok: result.ok }); + + if (result.ok) { + low = mid + 1; + } else { + breakingPoint = mid; + high = mid - 1; + } + } + + return { + breakingPoint, + attempts: history.length, + history + }; +} + +module.exports = { + findBreakingPoint +}; diff --git a/plugins/next-task/lib/perf/breaking-point-runner.js b/plugins/next-task/lib/perf/breaking-point-runner.js new file mode 100644 index 00000000..0f15d5af --- /dev/null +++ b/plugins/next-task/lib/perf/breaking-point-runner.js @@ -0,0 +1,60 @@ +/** + * Breaking point runner wrapper for /perf. + * + * @module lib/perf/breaking-point-runner + */ + +const { runBenchmark, parseMetrics, BINARY_SEARCH_MIN_DURATION } = require('./benchmark-runner'); +const { findBreakingPoint } = require('./breaking-point-finder'); + +/** + * Run a binary search to find the breaking point for a numeric parameter. + * The benchmark command should accept the value via an env var. + * + * @param {object} options + * @param {string} options.command + * @param {string} options.paramEnv + * @param {number} options.min + * @param {number} options.max + * @returns {Promise<{breakingPoint:number|null, attempts:number, history:Array}>} + */ +async function runBreakingPointSearch(options) { + const { command, paramEnv, min, max } = options || {}; + + if (!command || typeof command !== 'string') { + throw new Error('command must be a non-empty string'); + } + if (!paramEnv || typeof paramEnv !== 'string') { + throw new Error('paramEnv must be a non-empty string'); + } + if (typeof min !== 'number' || typeof max !== 'number') { + throw new Error('min and max must be numbers'); + } + + const runner = async (value) => { + try { + const result = runBenchmark(command, { + mode: 'binary-search', + duration: BINARY_SEARCH_MIN_DURATION, + env: { + [paramEnv]: String(value) + } + }); + + const parsed = parseMetrics(result.output); + if (!parsed.ok) { + return { ok: false, data: { error: parsed.error } }; + } + + return { ok: true, data: { metrics: parsed.metrics } }; + } catch (error) { + return { ok: false, data: { error: error.message } }; + } + }; + + return findBreakingPoint({ min, max, runner }); +} + +module.exports = { + runBreakingPointSearch +}; diff --git a/plugins/next-task/lib/perf/checkpoint.js b/plugins/next-task/lib/perf/checkpoint.js new file mode 100644 index 00000000..8926f855 --- /dev/null +++ b/plugins/next-task/lib/perf/checkpoint.js @@ -0,0 +1,99 @@ +/** + * Git checkpoint helper for /perf phases. + * + * @module lib/perf/checkpoint + */ + +const { execSync, execFileSync } = require('child_process'); + +/** + * Check if git repo is clean. + * @returns {boolean} + */ +function isWorkingTreeClean() { + const output = execSync('git status --porcelain', { encoding: 'utf8' }).trim(); + return output.length === 0; +} + +/** + * Build checkpoint commit message. + * @param {object} input + * @param {string} input.phase + * @param {string} input.id + * @param {string} [input.baselineVersion] + * @param {string} [input.deltaSummary] + * @returns {string} + */ +function buildCheckpointMessage(input) { + if (!input || typeof input !== 'object') { + throw new Error('Checkpoint input must be an object'); + } + const { phase, id, baselineVersion, deltaSummary } = input; + + if (!phase || typeof phase !== 'string') { + throw new Error('phase is required'); + } + if (!id || typeof id !== 'string') { + throw new Error('id is required'); + } + + const baseline = baselineVersion || 'n/a'; + const delta = deltaSummary || 'n/a'; + return `perf: phase ${phase} [${id}] baseline=${baseline} delta=${delta}`; +} + +/** + * Get the most recent git commit message. + * @returns {string|null} + */ +function getLastCommitMessage() { + try { + return execSync('git log -1 --pretty=%B', { encoding: 'utf8' }).trim(); + } catch { + return null; + } +} + +/** + * Check if the next checkpoint would duplicate the last commit. + * @param {string} message + * @returns {boolean} + */ +function isDuplicateCheckpoint(message) { + const last = getLastCommitMessage(); + if (!last) return false; + return last.trim() === String(message || '').trim(); +} + +/** + * Commit a checkpoint for a perf phase. + * @param {object} input + * @returns {{ ok: boolean, message?: string, reason?: string }} + */ +function commitCheckpoint(input) { + try { + execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' }); + } catch { + return { ok: false, reason: 'not a git repo' }; + } + + if (isWorkingTreeClean()) { + return { ok: false, reason: 'nothing to commit' }; + } + + const message = buildCheckpointMessage(input); + if (isDuplicateCheckpoint(message)) { + return { ok: false, reason: 'duplicate checkpoint' }; + } + execFileSync('git', ['add', '-A'], { stdio: 'ignore' }); + execFileSync('git', ['commit', '-m', message], { stdio: 'ignore' }); + return { ok: true, message }; +} + +module.exports = { + isWorkingTreeClean, + buildCheckpointMessage, + getLastCommitMessage, + isDuplicateCheckpoint, + commitCheckpoint +}; diff --git a/plugins/next-task/lib/perf/code-paths.js b/plugins/next-task/lib/perf/code-paths.js new file mode 100644 index 00000000..ece2c8bf --- /dev/null +++ b/plugins/next-task/lib/perf/code-paths.js @@ -0,0 +1,86 @@ +/** + * Code-path discovery helpers for /perf. + * + * @module lib/perf/code-paths + */ + +const DEFAULT_STOPWORDS = new Set([ + 'the', 'and', 'for', 'with', 'from', 'that', 'this', 'these', 'those', + 'into', 'over', 'under', 'than', 'then', 'when', 'where', 'what', 'which', + 'your', 'you', 'our', 'their', 'there', 'have', 'has', 'had', 'will', + 'would', 'should', 'could', 'about', 'across', 'after', 'before', 'while', + 'perf', 'performance', 'investigation', 'baseline', 'benchmark', 'scenario' +]); + +function normalizeKeywords(text) { + if (!text || typeof text !== 'string') return []; + const tokens = text + .toLowerCase() + .split(/[^a-z0-9]+/g) + .filter(Boolean) + .filter(token => token.length > 2) + .filter(token => !DEFAULT_STOPWORDS.has(token)); + + return Array.from(new Set(tokens)); +} + +function scoreEntry(entry, keywords) { + let score = 0; + if (!entry || keywords.length === 0) return score; + + const haystack = [ + entry.file || '', + ...(entry.symbols || []) + ].join(' ').toLowerCase(); + + for (const keyword of keywords) { + if (haystack.includes(keyword)) score += 1; + } + + return score; +} + +function extractSymbols(fileData) { + if (!fileData || !fileData.symbols) return []; + const symbols = []; + for (const group of Object.values(fileData.symbols)) { + if (!Array.isArray(group)) continue; + for (const symbol of group) { + if (symbol && symbol.name) symbols.push(symbol.name); + } + } + return symbols; +} + +function collectCodePaths(repoMap, scenario, limit = 12) { + if (!repoMap || !repoMap.files) { + return { keywords: normalizeKeywords(scenario), paths: [] }; + } + + const keywords = normalizeKeywords(scenario); + const candidates = []; + + for (const [file, data] of Object.entries(repoMap.files)) { + const symbols = extractSymbols(data); + const entry = { file, symbols }; + const score = scoreEntry(entry, keywords); + if (score <= 0) continue; + candidates.push({ ...entry, score }); + } + + candidates.sort((a, b) => b.score - a.score || a.file.localeCompare(b.file)); + + return { + keywords, + paths: candidates.slice(0, limit).map(item => ({ + file: item.file, + score: item.score, + symbols: item.symbols.slice(0, 8) + })) + }; +} + +module.exports = { + normalizeKeywords, + collectCodePaths +}; diff --git a/plugins/next-task/lib/perf/consolidation.js b/plugins/next-task/lib/perf/consolidation.js new file mode 100644 index 00000000..f8c292da --- /dev/null +++ b/plugins/next-task/lib/perf/consolidation.js @@ -0,0 +1,37 @@ +/** + * Baseline consolidation helper. + * + * @module lib/perf/consolidation + */ + +const baselineStore = require('./baseline-store'); + +/** + * Consolidate a baseline for a version (overwrite existing). + * @param {object} input + * @param {string} input.version + * @param {object} input.baseline + * @param {string} [basePath] + * @returns {{ version: string, path: string }} + */ +function consolidateBaseline(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('consolidateBaseline requires an input object'); + } + const { version, baseline } = input; + + if (!version || typeof version !== 'string') { + throw new Error('version is required'); + } + if (!baseline || typeof baseline !== 'object') { + throw new Error('baseline is required'); + } + + baselineStore.writeBaseline(version, baseline, basePath); + const path = baselineStore.getBaselinePath(version, basePath); + return { version, path }; +} + +module.exports = { + consolidateBaseline +}; diff --git a/plugins/next-task/lib/perf/constraint-runner.js b/plugins/next-task/lib/perf/constraint-runner.js new file mode 100644 index 00000000..a5c5f6a5 --- /dev/null +++ b/plugins/next-task/lib/perf/constraint-runner.js @@ -0,0 +1,69 @@ +/** + * Constraint testing runner for /perf. + * + * @module lib/perf/constraint-runner + */ + +const { runBenchmark, parseMetrics, DEFAULT_MIN_DURATION } = require('./benchmark-runner'); +const { compareBaselines } = require('./baseline-comparator'); + +/** + * Run baseline and constrained benchmarks sequentially. + * Constraints are provided via env vars to keep it cross-platform. + * + * @param {object} options + * @param {string} options.command + * @param {object} options.constraints + * @param {object} [options.env] + * @returns {{ constraints: object, baseline: object, constrained: object, delta: object }} + */ +function runConstraintTest(options) { + const { command, constraints, env } = options || {}; + + if (!command || typeof command !== 'string') { + throw new Error('command must be a non-empty string'); + } + if (!constraints || typeof constraints !== 'object' || Array.isArray(constraints)) { + throw new Error('constraints must be an object'); + } + + const baselineResult = runBenchmark(command, { + duration: DEFAULT_MIN_DURATION, + env: { + ...env + } + }); + const baselineMetrics = parseMetrics(baselineResult.output); + if (!baselineMetrics.ok) { + throw new Error(`Baseline metrics parse failed: ${baselineMetrics.error}`); + } + + const constrainedResult = runBenchmark(command, { + duration: DEFAULT_MIN_DURATION, + env: { + ...env, + PERF_CPU_LIMIT: constraints.cpu, + PERF_MEMORY_LIMIT: constraints.memory + } + }); + const constrainedMetrics = parseMetrics(constrainedResult.output); + if (!constrainedMetrics.ok) { + throw new Error(`Constrained metrics parse failed: ${constrainedMetrics.error}`); + } + + const delta = compareBaselines( + { metrics: baselineMetrics.metrics }, + { metrics: constrainedMetrics.metrics } + ); + + return { + constraints, + baseline: { metrics: baselineMetrics.metrics }, + constrained: { metrics: constrainedMetrics.metrics }, + delta + }; +} + +module.exports = { + runConstraintTest +}; diff --git a/plugins/next-task/lib/perf/experiment-runner.js b/plugins/next-task/lib/perf/experiment-runner.js new file mode 100644 index 00000000..fbee670d --- /dev/null +++ b/plugins/next-task/lib/perf/experiment-runner.js @@ -0,0 +1,32 @@ +/** + * Experiment runner utilities. + * + * @module lib/perf/experiment-runner + */ + +/** + * Run experiments sequentially (never parallel). + * @param {Array} experiments + * @param {(experiment:object)=>Promise} runner + * @returns {Promise<{results:Array}>} + */ +async function runExperiments(experiments, runner) { + if (!Array.isArray(experiments)) { + throw new Error('experiments must be an array'); + } + if (typeof runner !== 'function') { + throw new Error('runner must be a function'); + } + + const results = []; + for (const experiment of experiments) { + const result = await runner(experiment); + results.push(result); + } + + return { results }; +} + +module.exports = { + runExperiments +}; diff --git a/plugins/next-task/lib/perf/index.js b/plugins/next-task/lib/perf/index.js new file mode 100644 index 00000000..2a8a689a --- /dev/null +++ b/plugins/next-task/lib/perf/index.js @@ -0,0 +1,41 @@ +/** + * Performance investigation utilities + * + * @module lib/perf + */ + +const investigationState = require('./investigation-state'); +const baselineStore = require('./baseline-store'); +const baselineComparator = require('./baseline-comparator'); +const benchmarkRunner = require('./benchmark-runner'); +const breakingPointFinder = require('./breaking-point-finder'); +const breakingPointRunner = require('./breaking-point-runner'); +const experimentRunner = require('./experiment-runner'); +const constraintRunner = require('./constraint-runner'); +const checkpoint = require('./checkpoint'); +const profilingRunner = require('./profiling-runner'); +const optimizationRunner = require('./optimization-runner'); +const consolidation = require('./consolidation'); +const profilers = require('./profilers'); +const analyzer = require('./analyzer'); +const argumentParser = require('./argument-parser'); +const codePaths = require('./code-paths'); + +module.exports = { + investigationState, + baselineStore, + baselineComparator, + benchmarkRunner, + breakingPointFinder, + breakingPointRunner, + experimentRunner, + constraintRunner, + checkpoint, + profilingRunner, + optimizationRunner, + consolidation, + profilers, + analyzer, + argumentParser, + codePaths +}; diff --git a/plugins/next-task/lib/perf/investigation-state.js b/plugins/next-task/lib/perf/investigation-state.js new file mode 100644 index 00000000..3bb091df --- /dev/null +++ b/plugins/next-task/lib/perf/investigation-state.js @@ -0,0 +1,788 @@ +/** + * Performance investigation state management + * + * Stores investigation state and logs under the platform-aware state directory: + * - {state-dir}/perf/investigation.json + * - {state-dir}/perf/investigations/{id}.md + * + * @module lib/perf/investigation-state + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { getStateDir } = require('../platform/state-dir'); +const { validateInvestigationState, assertValid } = require('./schemas'); + +const SCHEMA_VERSION = 1; +const INVESTIGATION_FILE = 'investigation.json'; +const LOG_DIR = 'investigations'; +const BASELINE_DIR = 'baselines'; + +const PHASES = [ + 'setup', + 'baseline', + 'breaking-point', + 'constraints', + 'hypotheses', + 'code-paths', + 'profiling', + 'optimization', + 'decision', + 'consolidation' +]; + +/** + * Validate and resolve path to prevent path traversal attacks + * @param {string} basePath - Base directory path + * @returns {string} Validated absolute path + */ +function validatePath(basePath) { + if (typeof basePath !== 'string' || basePath.length === 0) { + throw new Error('Path must be a non-empty string'); + } + const resolved = path.resolve(basePath); + if (resolved.includes('\0')) { + throw new Error('Path contains invalid null byte'); + } + return resolved; +} + +/** + * Validate that target path is within base directory + * @param {string} targetPath - Target file path + * @param {string} basePath - Base directory + */ +function validatePathWithinBase(targetPath, basePath) { + const resolvedTarget = path.resolve(targetPath); + const resolvedBase = path.resolve(basePath); + if (!resolvedTarget.startsWith(resolvedBase + path.sep) && resolvedTarget !== resolvedBase) { + throw new Error('Path traversal detected'); + } +} + +function assertSafeInvestigationId(id) { + if (!id || typeof id !== 'string') { + throw new Error('Investigation id is required'); + } + if (id.includes('..') || id.includes('/') || id.includes('\\') || id.includes('\0')) { + throw new Error('Investigation id contains invalid characters'); + } + if (!/^[a-zA-Z0-9._-]+$/.test(id)) { + throw new Error('Investigation id contains invalid characters'); + } + return id; +} + +/** + * Generate a unique investigation ID + * @returns {string} + */ +function generateInvestigationId() { + const now = new Date(); + const date = now.toISOString().slice(0, 10).replace(/-/g, ''); + const time = now.toISOString().slice(11, 19).replace(/:/g, ''); + const random = crypto.randomBytes(4).toString('hex'); + return `perf-${date}-${time}-${random}`; +} + +/** + * Get perf state directory path + * @param {string} basePath + * @returns {string} + */ +function getPerfDir(basePath = process.cwd()) { + const validatedBase = validatePath(basePath); + const perfDir = path.join(validatedBase, getStateDir(basePath), 'perf'); + validatePathWithinBase(perfDir, validatedBase); + return perfDir; +} + +/** + * Ensure perf directories exist + * @param {string} basePath + * @returns {{ perfDir: string, logDir: string, baselineDir: string }} + */ +function ensurePerfDirs(basePath = process.cwd()) { + const perfDir = getPerfDir(basePath); + const logDir = path.join(perfDir, LOG_DIR); + const baselineDir = path.join(perfDir, BASELINE_DIR); + + if (!fs.existsSync(perfDir)) { + fs.mkdirSync(perfDir, { recursive: true }); + } + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + if (!fs.existsSync(baselineDir)) { + fs.mkdirSync(baselineDir, { recursive: true }); + } + + return { perfDir, logDir, baselineDir }; +} + +/** + * Get path to investigation.json + * @param {string} basePath + * @returns {string} + */ +function getInvestigationPath(basePath = process.cwd()) { + const perfDir = getPerfDir(basePath); + return path.join(perfDir, INVESTIGATION_FILE); +} + +/** + * Get path to investigation log + * @param {string} id + * @param {string} basePath + * @returns {string} + */ +function getInvestigationLogPath(id, basePath = process.cwd()) { + const safeId = assertSafeInvestigationId(id); + const { logDir } = ensurePerfDirs(basePath); + return path.join(logDir, `${safeId}.md`); +} + +/** + * Read investigation.json + * @param {string} basePath + * @returns {object|null} + */ +function readInvestigation(basePath = process.cwd()) { + const investigationPath = getInvestigationPath(basePath); + if (!fs.existsSync(investigationPath)) { + return null; + } + try { + const parsed = JSON.parse(fs.readFileSync(investigationPath, 'utf8')); + const validation = validateInvestigationState(parsed); + if (!validation.ok) { + console.error(`[CRITICAL] Invalid investigation state at ${investigationPath}: ${validation.errors.join(', ')}`); + return null; + } + return parsed; + } catch (error) { + console.error(`[CRITICAL] Corrupted investigation.json at ${investigationPath}: ${error.message}`); + return null; + } +} + +/** + * Write investigation.json + * @param {object} state + * @param {string} basePath + * @returns {boolean} + */ +function writeInvestigation(state, basePath = process.cwd()) { + ensurePerfDirs(basePath); + const investigationPath = getInvestigationPath(basePath); + const nextState = { ...state, updatedAt: new Date().toISOString() }; + assertValid(validateInvestigationState(nextState), 'Invalid investigation state'); + fs.writeFileSync(investigationPath, JSON.stringify(nextState, null, 2), 'utf8'); + return true; +} + +/** + * Update investigation.json with partial updates + * @param {object} updates + * @param {string} basePath + * @returns {object|null} + */ +function updateInvestigation(updates, basePath = process.cwd()) { + const current = readInvestigation(basePath) || {}; + const nextState = { ...current }; + + for (const [key, value] of Object.entries(updates)) { + if (value === null) { + nextState[key] = null; + } else if ( + value && typeof value === 'object' && !Array.isArray(value) && + nextState[key] && typeof nextState[key] === 'object' && !Array.isArray(nextState[key]) + ) { + nextState[key] = { ...nextState[key], ...value }; + } else { + nextState[key] = value; + } + } + + writeInvestigation(nextState, basePath); + return readInvestigation(basePath); +} + +/** + * Initialize a new investigation + * @param {object} options + * @param {string} basePath + * @returns {object} + */ +function initializeInvestigation(options = {}, basePath = process.cwd()) { + const id = options.id || generateInvestigationId(); + const phase = options.phase || PHASES[0]; + + if (!PHASES.includes(phase)) { + throw new Error(`Invalid perf phase: ${phase}`); + } + + const state = { + schemaVersion: SCHEMA_VERSION, + id, + status: 'in_progress', + phase, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + scenario: { + description: options.scenario || '', + metrics: options.metrics || [], + successCriteria: options.successCriteria || '', + scenarios: Array.isArray(options.scenarios) ? options.scenarios : [] + }, + baselines: [], + hypotheses: [], + codePaths: [], + experiments: [], + results: [], + breakingPoint: null, + breakingPointHistory: [], + constraintResults: [], + profilingResults: [], + decision: null + }; + + assertValid(validateInvestigationState(state), 'Invalid initial investigation state'); + writeInvestigation(state, basePath); + return state; +} + +/** + * Append a line to the investigation log + * @param {string} id + * @param {string} content + * @param {string} basePath + */ +function appendInvestigationLog(id, content, basePath = process.cwd()) { + if (!content) return; + const logPath = getInvestigationLogPath(id, basePath); + const entry = content.endsWith('\n') ? content : `${content}\n`; + fs.appendFileSync(logPath, entry, 'utf8'); +} + +/** + * Append a baseline section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.command + * @param {object} input.metrics + * @param {string} input.baselinePath + * @param {string} [input.date] + * @param {string} basePath + */ +function appendBaselineLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendBaselineLog requires an input object'); + } + + const { id, userQuote, command, metrics, baselinePath, date, scenarios } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendBaselineLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendBaselineLog requires a non-empty userQuote'); + } + if (!command || typeof command !== 'string') { + throw new Error('appendBaselineLog requires a non-empty command'); + } + if (!metrics || typeof metrics !== 'object' || Array.isArray(metrics)) { + throw new Error('appendBaselineLog requires a metrics object'); + } + if (!baselinePath || typeof baselinePath !== 'string') { + throw new Error('appendBaselineLog requires a baselinePath'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const metricsText = JSON.stringify(metrics); + const scenarioText = Array.isArray(scenarios) && scenarios.length > 0 + ? scenarios.map((scenario) => scenario.name).filter(Boolean).join(', ') + : ''; + + const entry = [ + `## Baseline - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + scenarioText ? `- Scenarios: ${scenarioText}` : null, + `- Baseline command: \`${command}\``, + `- Metrics: ${metricsText}`, + '', + '**Evidence**', + `- Baseline file: ${baselinePath}`, + '' + ].filter(Boolean).join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a profiling section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.tool + * @param {string} input.command + * @param {string[]} input.artifacts + * @param {string[]} input.hotspots + * @param {string} [input.date] + * @param {string} basePath + */ +function appendProfilingLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendProfilingLog requires an input object'); + } + + const { id, userQuote, tool, command, artifacts, hotspots, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendProfilingLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendProfilingLog requires a non-empty userQuote'); + } + if (!tool || typeof tool !== 'string') { + throw new Error('appendProfilingLog requires a tool'); + } + if (!command || typeof command !== 'string') { + throw new Error('appendProfilingLog requires a command'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const artifactList = Array.isArray(artifacts) ? artifacts : []; + const hotspotList = Array.isArray(hotspots) ? hotspots : []; + + const entry = [ + `## Profiling - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Tool: ${tool}`, + `- Command: \`${command}\``, + '', + '**Evidence**', + artifactList.length ? `- Artifacts: ${artifactList.join(', ')}` : '- Artifacts: n/a', + hotspotList.length ? `- Hotspots: ${hotspotList.join(', ')}` : '- Hotspots: n/a', + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a decision section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.verdict + * @param {string} input.rationale + * @param {string} [input.date] + * @param {string} basePath + */ +function appendDecisionLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendDecisionLog requires an input object'); + } + + const { id, userQuote, verdict, rationale, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendDecisionLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendDecisionLog requires a non-empty userQuote'); + } + if (!verdict || typeof verdict !== 'string') { + throw new Error('appendDecisionLog requires a verdict'); + } + if (!rationale || typeof rationale !== 'string') { + throw new Error('appendDecisionLog requires a rationale'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + + const entry = [ + `## Decision - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Verdict: ${verdict}`, + `- Rationale: ${rationale}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a setup section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.scenario + * @param {string} input.command + * @param {string} input.version + * @param {string} [input.date] + * @param {string} basePath + */ +function appendSetupLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendSetupLog requires an input object'); + } + + const { id, userQuote, scenario, command, version, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendSetupLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendSetupLog requires a non-empty userQuote'); + } + if (!scenario || typeof scenario !== 'string') { + throw new Error('appendSetupLog requires a scenario'); + } + if (!command || typeof command !== 'string') { + throw new Error('appendSetupLog requires a command'); + } + if (!version || typeof version !== 'string') { + throw new Error('appendSetupLog requires a version'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Setup - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Scenario: ${scenario}`, + `- Command: \`${command}\``, + `- Version: ${version}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a breaking point section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.paramEnv + * @param {number} input.min + * @param {number} input.max + * @param {number|null} input.breakingPoint + * @param {string} [input.date] + * @param {string} basePath + */ +function appendBreakingPointLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendBreakingPointLog requires an input object'); + } + const { id, userQuote, paramEnv, min, max, breakingPoint, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendBreakingPointLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendBreakingPointLog requires a non-empty userQuote'); + } + if (!paramEnv || typeof paramEnv !== 'string') { + throw new Error('appendBreakingPointLog requires a paramEnv'); + } + if (typeof min !== 'number' || typeof max !== 'number') { + throw new Error('appendBreakingPointLog requires numeric min/max'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Breaking Point - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Param env: ${paramEnv}`, + `- Range: ${min}..${max}`, + `- Breaking point: ${breakingPoint ?? 'n/a'}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a constraints section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {object} input.constraints + * @param {object} input.delta + * @param {string} [input.date] + * @param {string} basePath + */ +function appendConstraintLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendConstraintLog requires an input object'); + } + const { id, userQuote, constraints, delta, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendConstraintLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendConstraintLog requires a non-empty userQuote'); + } + if (!constraints || typeof constraints !== 'object') { + throw new Error('appendConstraintLog requires constraints'); + } + if (!delta || typeof delta !== 'object') { + throw new Error('appendConstraintLog requires delta'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Constraints - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- CPU: ${constraints.cpu || 'n/a'}`, + `- Memory: ${constraints.memory || 'n/a'}`, + '', + '**Evidence**', + `- Delta: ${JSON.stringify(delta.metrics || {})}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a hypotheses section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {Array} input.hypotheses + * @param {string} [input.date] + * @param {string} basePath + */ +function appendHypothesesLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendHypothesesLog requires an input object'); + } + const { id, userQuote, hypotheses, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendHypothesesLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendHypothesesLog requires a non-empty userQuote'); + } + if (!Array.isArray(hypotheses)) { + throw new Error('appendHypothesesLog requires hypotheses array'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const lines = hypotheses.map((item) => { + if (!item) return null; + const label = item.id ? `${item.id}: ` : ''; + const evidence = item.evidence ? ` (evidence: ${item.evidence})` : ''; + const confidence = item.confidence ? ` [${item.confidence}]` : ''; + return `- ${label}${item.hypothesis || 'n/a'}${confidence}${evidence}`; + }).filter(Boolean); + + const entry = [ + `## Hypotheses - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + lines.length > 0 ? lines.join('\n') : '- n/a', + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a code-paths section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string[]} input.keywords + * @param {Array} input.paths + * @param {string} [input.date] + * @param {string} basePath + */ +function appendCodePathsLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendCodePathsLog requires an input object'); + } + const { id, userQuote, keywords, paths, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendCodePathsLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendCodePathsLog requires a non-empty userQuote'); + } + if (!Array.isArray(paths)) { + throw new Error('appendCodePathsLog requires paths array'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const keywordText = Array.isArray(keywords) && keywords.length > 0 ? keywords.join(', ') : 'n/a'; + const pathLines = paths.map((pathEntry) => { + const file = pathEntry.file || 'n/a'; + const score = typeof pathEntry.score === 'number' ? ` (score: ${pathEntry.score})` : ''; + const symbols = Array.isArray(pathEntry.symbols) && pathEntry.symbols.length > 0 + ? ` [${pathEntry.symbols.join(', ')}]` + : ''; + return `- ${file}${score}${symbols}`; + }); + + const entry = [ + `## Code Paths - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Keywords: ${keywordText}`, + pathLines.length > 0 ? pathLines.join('\n') : '- n/a', + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append an optimization section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.change + * @param {object} input.delta + * @param {string} input.verdict + * @param {string} [input.date] + * @param {string} basePath + */ +function appendOptimizationLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendOptimizationLog requires an input object'); + } + const { id, userQuote, change, delta, verdict, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendOptimizationLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendOptimizationLog requires a non-empty userQuote'); + } + if (!change || typeof change !== 'string') { + throw new Error('appendOptimizationLog requires a change summary'); + } + if (!delta || typeof delta !== 'object') { + throw new Error('appendOptimizationLog requires delta'); + } + if (!verdict || typeof verdict !== 'string') { + throw new Error('appendOptimizationLog requires a verdict'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Optimization - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Change: ${change}`, + `- Verdict: ${verdict}`, + '', + '**Evidence**', + `- Delta: ${JSON.stringify(delta.metrics || {})}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +/** + * Append a consolidation section to the investigation log + * @param {object} input + * @param {string} input.id + * @param {string} input.userQuote + * @param {string} input.version + * @param {string} input.path + * @param {string} [input.date] + * @param {string} basePath + */ +function appendConsolidationLog(input, basePath = process.cwd()) { + if (!input || typeof input !== 'object') { + throw new Error('appendConsolidationLog requires an input object'); + } + + const { id, userQuote, version, path, date } = input; + + if (!id || typeof id !== 'string') { + throw new Error('appendConsolidationLog requires a valid investigation id'); + } + if (!userQuote || typeof userQuote !== 'string') { + throw new Error('appendConsolidationLog requires a non-empty userQuote'); + } + if (!version || typeof version !== 'string') { + throw new Error('appendConsolidationLog requires a version'); + } + if (!path || typeof path !== 'string') { + throw new Error('appendConsolidationLog requires a path'); + } + + const logDate = date || new Date().toISOString().slice(0, 10); + const entry = [ + `## Consolidation - ${logDate}`, + '', + `**User Quote:** "${userQuote}"`, + '', + '**Summary**', + `- Version: ${version}`, + `- Baseline file: ${path}`, + '' + ].join('\n'); + + appendInvestigationLog(id, entry, basePath); +} + +module.exports = { + SCHEMA_VERSION, + PHASES, + generateInvestigationId, + getPerfDir, + ensurePerfDirs, + getInvestigationPath, + getInvestigationLogPath, + readInvestigation, + writeInvestigation, + updateInvestigation, + initializeInvestigation, + appendInvestigationLog, + appendBaselineLog, + appendProfilingLog, + appendDecisionLog, + appendSetupLog, + appendBreakingPointLog, + appendConstraintLog, + appendHypothesesLog, + appendCodePathsLog, + appendOptimizationLog, + appendConsolidationLog +}; diff --git a/plugins/next-task/lib/perf/optimization-runner.js b/plugins/next-task/lib/perf/optimization-runner.js new file mode 100644 index 00000000..4fb6ca0d --- /dev/null +++ b/plugins/next-task/lib/perf/optimization-runner.js @@ -0,0 +1,67 @@ +/** + * Optimization runner for /perf experiments. + * + * @module lib/perf/optimization-runner + */ + +const { runBenchmark, parseMetrics, DEFAULT_MIN_DURATION } = require('./benchmark-runner'); +const { compareBaselines } = require('./baseline-comparator'); +const { isWorkingTreeClean } = require('./checkpoint'); + +/** + * Run a single optimization experiment with two benchmark runs. + * NOTE: This helper does not modify code; it assumes the change was applied externally. + * + * @param {object} options + * @param {string} options.command + * @param {string} options.changeSummary + * @param {object} [options.env] + * @returns {{ baseline: object, experiment: object, delta: object, verdict: string, change: string }} + */ +function runOptimizationExperiment(options) { + const { command, changeSummary, env } = options || {}; + + if (!command || typeof command !== 'string') { + throw new Error('command must be a non-empty string'); + } + if (!changeSummary || typeof changeSummary !== 'string') { + throw new Error('changeSummary must be a non-empty string'); + } + + const shouldCheckClean = options?.requireClean !== false; + if (shouldCheckClean && !isWorkingTreeClean()) { + throw new Error('working tree is dirty before experiment'); + } + + const baselineRun = runBenchmark(command, { duration: DEFAULT_MIN_DURATION, env }); + const baselineMetrics = parseMetrics(baselineRun.output); + if (!baselineMetrics.ok) { + throw new Error(`Baseline parse failed: ${baselineMetrics.error}`); + } + + // NOTE: Caller is responsible for applying the experiment change here. + // Warm up the system (caches/JIT) before capturing experiment metrics. + runBenchmark(command, { duration: DEFAULT_MIN_DURATION, env }); + const experimentRun = runBenchmark(command, { duration: DEFAULT_MIN_DURATION, env }); + const experimentMetrics = parseMetrics(experimentRun.output); + if (!experimentMetrics.ok) { + throw new Error(`Experiment parse failed: ${experimentMetrics.error}`); + } + + const delta = compareBaselines( + { metrics: baselineMetrics.metrics }, + { metrics: experimentMetrics.metrics } + ); + + return { + change: changeSummary, + baseline: { metrics: baselineMetrics.metrics }, + experiment: { metrics: experimentMetrics.metrics }, + delta, + verdict: 'inconclusive' + }; +} + +module.exports = { + runOptimizationExperiment +}; diff --git a/plugins/next-task/lib/perf/profilers/go.js b/plugins/next-task/lib/perf/profilers/go.js new file mode 100644 index 00000000..9616ae6e --- /dev/null +++ b/plugins/next-task/lib/perf/profilers/go.js @@ -0,0 +1,22 @@ +/** + * Go pprof helper. + * + * @module lib/perf/profilers/go + */ + +module.exports = { + id: 'pprof', + tool: 'pprof', + buildCommand(options = {}) { + const command = options.command || 'go test'; + const output = options.output || 'cpu.pprof'; + return `${command} -cpuprofile=${output}`; + }, + parseOutput() { + return { + tool: 'pprof', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/next-task/lib/perf/profilers/index.js b/plugins/next-task/lib/perf/profilers/index.js new file mode 100644 index 00000000..20e66af9 --- /dev/null +++ b/plugins/next-task/lib/perf/profilers/index.js @@ -0,0 +1,46 @@ +/** + * Profilers registry for /perf. + * + * @module lib/perf/profilers + */ + +const fs = require('fs'); +const path = require('path'); +const cliEnhancers = require('../../patterns/cli-enhancers'); +const nodeProfiler = require('./node'); +const pythonProfiler = require('./python'); +const goProfiler = require('./go'); +const rustProfiler = require('./rust'); +const javaProfiler = require('./java'); + +function hasJavaIndicators(repoPath) { + const indicators = ['pom.xml', 'build.gradle', 'build.gradle.kts']; + return indicators.some((file) => fs.existsSync(path.join(repoPath, file))); +} + +function selectProfiler(repoPath = process.cwd()) { + const languages = cliEnhancers.detectProjectLanguages(repoPath); + + if (hasJavaIndicators(repoPath)) return javaProfiler; + if (languages.includes('typescript') || languages.includes('javascript')) return nodeProfiler; + if (languages.includes('go')) return goProfiler; + if (languages.includes('python')) return pythonProfiler; + if (languages.includes('rust')) return rustProfiler; + + return nodeProfiler; +} + +function listAvailable() { + return [ + nodeProfiler.id, + javaProfiler.id, + pythonProfiler.id, + goProfiler.id, + rustProfiler.id + ]; +} + +module.exports = { + listAvailable, + selectProfiler +}; diff --git a/plugins/next-task/lib/perf/profilers/java.js b/plugins/next-task/lib/perf/profilers/java.js new file mode 100644 index 00000000..bb464130 --- /dev/null +++ b/plugins/next-task/lib/perf/profilers/java.js @@ -0,0 +1,23 @@ +/** + * Java JFR profiler helper. + * + * @module lib/perf/profilers/java + */ + +module.exports = { + id: 'jfr', + tool: 'jfr', + buildCommand(options = {}) { + const command = options.command || 'java'; + const output = options.output || 'profile.jfr'; + const duration = options.duration || '60s'; + return `${command} -XX:StartFlightRecording=duration=${duration},filename=${output}`; + }, + parseOutput() { + return { + tool: 'jfr', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/next-task/lib/perf/profilers/node.js b/plugins/next-task/lib/perf/profilers/node.js new file mode 100644 index 00000000..95b7a857 --- /dev/null +++ b/plugins/next-task/lib/perf/profilers/node.js @@ -0,0 +1,22 @@ +/** + * Node.js profiler helper. + * + * @module lib/perf/profilers/node + */ + +module.exports = { + id: 'node', + tool: '--cpu-prof', + buildCommand(options = {}) { + const command = options.command || 'node'; + const output = options.output || 'node.cpuprofile'; + return `${command} --cpu-prof --cpu-prof-name=${output}`; + }, + parseOutput() { + return { + tool: 'node', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/next-task/lib/perf/profilers/python.js b/plugins/next-task/lib/perf/profilers/python.js new file mode 100644 index 00000000..98ede075 --- /dev/null +++ b/plugins/next-task/lib/perf/profilers/python.js @@ -0,0 +1,23 @@ +/** + * Python cProfile helper. + * + * @module lib/perf/profilers/python + */ + +module.exports = { + id: 'cprofile', + tool: 'cProfile', + buildCommand(options = {}) { + const command = options.command || 'python'; + const target = options.target || '-m'; + const output = options.output || 'profile.prof'; + return `${command} -m cProfile -o ${output} ${target}`; + }, + parseOutput() { + return { + tool: 'cprofile', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/next-task/lib/perf/profilers/rust.js b/plugins/next-task/lib/perf/profilers/rust.js new file mode 100644 index 00000000..416186d6 --- /dev/null +++ b/plugins/next-task/lib/perf/profilers/rust.js @@ -0,0 +1,23 @@ +/** + * Rust perf helper (Linux). + * + * @module lib/perf/profilers/rust + */ + +module.exports = { + id: 'perf', + tool: 'perf', + buildCommand(options = {}) { + const command = options.command || 'perf record'; + const output = options.output || 'perf.data'; + const target = options.target || './target/release/app'; + return `${command} -o ${output} ${target}`; + }, + parseOutput() { + return { + tool: 'perf', + hotspots: [], + artifacts: [] + }; + } +}; diff --git a/plugins/next-task/lib/perf/profiling-runner.js b/plugins/next-task/lib/perf/profiling-runner.js new file mode 100644 index 00000000..e1204f25 --- /dev/null +++ b/plugins/next-task/lib/perf/profiling-runner.js @@ -0,0 +1,48 @@ +/** + * Profiling execution helper. + * + * @module lib/perf/profiling-runner + */ + +const { execSync } = require('child_process'); +const profilers = require('./profilers'); + +/** + * Run a profiling command and return artifacts/hotspots metadata. + * @param {object} options + * @param {string} [options.repoPath] + * @param {object} [options.profileOptions] + * @returns {{ ok: boolean, result?: object, error?: string }} + */ +function runProfiling(options = {}) { + const repoPath = options.repoPath || process.cwd(); + const profiler = profilers.selectProfiler(repoPath); + + if (!profiler || typeof profiler.buildCommand !== 'function') { + return { ok: false, error: 'No profiler available' }; + } + + const command = profiler.buildCommand(options.profileOptions || {}); + try { + execSync(command, { stdio: 'pipe' }); + } catch (error) { + return { ok: false, error: error.message }; + } + + const parsed = typeof profiler.parseOutput === 'function' + ? profiler.parseOutput() + : { tool: profiler.id, hotspots: [], artifacts: [] }; + + const result = { + tool: profiler.id, + command, + hotspots: parsed.hotspots || [], + artifacts: parsed.artifacts || [] + }; + + return { ok: true, result }; +} + +module.exports = { + runProfiling +}; diff --git a/plugins/next-task/lib/perf/schemas.js b/plugins/next-task/lib/perf/schemas.js new file mode 100644 index 00000000..8b86761e --- /dev/null +++ b/plugins/next-task/lib/perf/schemas.js @@ -0,0 +1,140 @@ +/** + * Schema validation helpers for /perf. + * + * @module lib/perf/schemas + */ + +const REQUIRED_INVESTIGATION_FIELDS = ['schemaVersion', 'id', 'status', 'phase', 'scenario']; +const REQUIRED_BASELINE_FIELDS = ['version', 'recordedAt', 'metrics', 'command']; + +function isObject(value) { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function validateInvestigationState(state) { + const errors = []; + + if (!isObject(state)) { + return { ok: false, errors: ['state must be an object'] }; + } + + for (const field of REQUIRED_INVESTIGATION_FIELDS) { + if (!Object.prototype.hasOwnProperty.call(state, field)) { + errors.push(`missing ${field}`); + } + } + + if (typeof state.id !== 'string' || state.id.trim().length === 0) { + errors.push('id must be a non-empty string'); + } + + if (typeof state.phase !== 'string' || state.phase.trim().length === 0) { + errors.push('phase must be a non-empty string'); + } + + if (!isObject(state.scenario)) { + errors.push('scenario must be an object'); + } else { + if (typeof state.scenario.description !== 'string') { + errors.push('scenario.description must be a string'); + } + if (!Array.isArray(state.scenario.metrics)) { + errors.push('scenario.metrics must be an array'); + } + if (typeof state.scenario.successCriteria !== 'string') { + errors.push('scenario.successCriteria must be a string'); + } + if (state.scenario.scenarios != null) { + if (!Array.isArray(state.scenario.scenarios)) { + errors.push('scenario.scenarios must be an array when provided'); + } else { + state.scenario.scenarios.forEach((scenario, index) => { + if (!isObject(scenario)) { + errors.push(`scenario.scenarios[${index}] must be an object`); + return; + } + if (typeof scenario.name !== 'string' || scenario.name.trim().length === 0) { + errors.push(`scenario.scenarios[${index}].name must be a non-empty string`); + } + if (scenario.params != null && !isObject(scenario.params)) { + errors.push(`scenario.scenarios[${index}].params must be an object when provided`); + } + }); + } + } + } + + return { ok: errors.length === 0, errors }; +} + +function validateBaseline(baseline) { + const errors = []; + + if (!isObject(baseline)) { + return { ok: false, errors: ['baseline must be an object'] }; + } + + for (const field of REQUIRED_BASELINE_FIELDS) { + if (!Object.prototype.hasOwnProperty.call(baseline, field)) { + errors.push(`missing ${field}`); + } + } + + if (typeof baseline.version !== 'string' || baseline.version.trim().length === 0) { + errors.push('version must be a non-empty string'); + } + + if (typeof baseline.recordedAt !== 'string' || baseline.recordedAt.trim().length === 0) { + errors.push('recordedAt must be an ISO8601 string'); + } + + if (typeof baseline.command !== 'string' || baseline.command.trim().length === 0) { + errors.push('command must be a non-empty string'); + } + + if (!isObject(baseline.metrics)) { + errors.push('metrics must be an object'); + } else { + if (baseline.metrics.scenarios != null) { + if (!isObject(baseline.metrics.scenarios)) { + errors.push('metrics.scenarios must be an object when provided'); + } else { + for (const [scenarioName, scenarioMetrics] of Object.entries(baseline.metrics.scenarios)) { + if (!isObject(scenarioMetrics)) { + errors.push(`metrics.scenarios.${scenarioName} must be an object`); + continue; + } + for (const [key, value] of Object.entries(scenarioMetrics)) { + if (typeof value !== 'number' || Number.isNaN(value)) { + errors.push(`metric ${scenarioName}.${key} must be a number`); + } + } + } + } + } else { + for (const [key, value] of Object.entries(baseline.metrics)) { + if (typeof value !== 'number' || Number.isNaN(value)) { + errors.push(`metric ${key} must be a number`); + } + } + } + } + + if (baseline.env && !isObject(baseline.env)) { + errors.push('env must be an object when provided'); + } + + return { ok: errors.length === 0, errors }; +} + +function assertValid(result, message) { + if (!result.ok) { + throw new Error(`${message}: ${result.errors.join(', ')}`); + } +} + +module.exports = { + validateInvestigationState, + validateBaseline, + assertValid +}; diff --git a/plugins/perf/.claude-plugin/plugin.json b/plugins/perf/.claude-plugin/plugin.json new file mode 100644 index 00000000..c405dc41 --- /dev/null +++ b/plugins/perf/.claude-plugin/plugin.json @@ -0,0 +1,20 @@ +{ + "name": "perf", + "version": "3.3.3", + "description": "Rigorous performance investigation workflow with baselines, profiling, and evidence-backed decisions", + "author": { + "name": "Avi Fenesh", + "email": "[email protected]", + "url": "https://github.com/avifenesh" + }, + "homepage": "https://github.com/avifenesh/awesome-slash#perf", + "repository": "https://github.com/avifenesh/awesome-slash", + "license": "MIT", + "keywords": [ + "performance", + "benchmarks", + "profiling", + "optimization", + "baseline" + ] +} diff --git a/plugins/perf/README.md b/plugins/perf/README.md new file mode 100644 index 00000000..034235ed --- /dev/null +++ b/plugins/perf/README.md @@ -0,0 +1,40 @@ +# perf + +Rigorous performance investigation workflow. `/perf` enforces sequential benchmarks, minimum run durations, and evidence-backed decision-making. + +## Command + +``` +/perf +``` + +## What it does + +- Establishes a baseline and persists results under `{state-dir}/perf/` +- Runs controlled experiments one at a time +- Performs profiling and hotspot analysis +- Consolidates findings into a single baseline per version + +## Requirements + +All behavior is governed by: +- `docs/perf-requirements.md` +- `docs/perf-research-methodology.md` + +## Inputs + +- Hypotheses can be supplied via `--hypotheses-file ` during the hypotheses phase. + +## Skills + +- `perf-theory-gatherer` - Hypothesis generation (git history + evidence) +- `perf-code-paths` - Code-path discovery before profiling +- `perf-theory-tester` - Controlled experiments for hypotheses +- `perf-analyzer` - Evidence-backed perf recommendations +- `perf-investigation-logger` - Structured log entries with evidence + +## Artifacts + +- `{state-dir}/perf/investigation.json` +- `{state-dir}/perf/investigations/.md` +- `{state-dir}/perf/baselines/.json` diff --git a/plugins/perf/agents/perf-analyzer.md b/plugins/perf/agents/perf-analyzer.md new file mode 100644 index 00000000..2774018d --- /dev/null +++ b/plugins/perf/agents/perf-analyzer.md @@ -0,0 +1,40 @@ +--- +name: perf-analyzer +description: Synthesize perf findings into evidence-backed recommendations and decisions. +tools: Read, Write +model: opus +--- + +# Perf Analyzer + +You MUST follow `docs/perf-requirements.md` as the canonical contract. + +Synthesize investigation outputs into clear, evidence-backed recommendations. + +You MUST execute the perf-analyzer skill to produce the output. Do not bypass the skill. + +## Inputs + +- Baseline data +- Experiment results +- Profiling evidence +- Hypotheses tested +- Breaking point results + +## Output Format + +``` +summary: <2-3 sentences> +recommendations: + - + - +abandoned: + - +next_steps: + - +``` + +## Constraints + +- Only cite evidence that exists in logs or code. +- If data is insufficient, say so and request a re-run. diff --git a/plugins/perf/agents/perf-code-paths.md b/plugins/perf/agents/perf-code-paths.md new file mode 100644 index 00000000..0b1f0d1f --- /dev/null +++ b/plugins/perf/agents/perf-code-paths.md @@ -0,0 +1,12 @@ +--- +name: perf-code-paths +description: Map likely code paths for perf scenarios before profiling. +tools: Read, Grep, Glob +model: sonnet +--- + +# Perf Code Paths + +Identify code paths and entrypoints tied to a performance scenario. + +You MUST execute the perf-code-paths skill to produce the output. Do not bypass the skill. diff --git a/plugins/perf/agents/perf-investigation-logger.md b/plugins/perf/agents/perf-investigation-logger.md new file mode 100644 index 00000000..c80dedff --- /dev/null +++ b/plugins/perf/agents/perf-investigation-logger.md @@ -0,0 +1,44 @@ +--- +name: perf-investigation-logger +description: Append structured investigation notes with exact user quotes and rationale. +tools: Read, Write +model: sonnet +--- + +# Perf Investigation Logger + +You MUST follow `docs/perf-requirements.md` as the canonical contract. + +Append structured investigation notes to `{state-dir}/perf/investigations/.md`. + +## Required Content + +1. Exact user quotes (verbatim) +2. Phase summary +3. Decisions and rationale +4. Evidence pointers (files, metrics, commands) + +## Output Format + +``` +## - + +**User Quote:** "" + +**Summary** +- ... + +**Evidence** +- Command: `...` +- File: `path:line` + +**Decision** +- ... +``` + +## Constraints + +- Use `AI_STATE_DIR` for state path (default `.claude`). +- Do not paraphrase user quotes. + +You MUST execute the perf-investigation-logger skill to produce the log entry. Do not bypass the skill. diff --git a/plugins/perf/agents/perf-orchestrator.md b/plugins/perf/agents/perf-orchestrator.md new file mode 100644 index 00000000..ed7ea9de --- /dev/null +++ b/plugins/perf/agents/perf-orchestrator.md @@ -0,0 +1,311 @@ +--- +name: perf-orchestrator +description: Coordinate /perf investigations across all phases, enforcing non-negotiable perf rules. +tools: Read, Write, Edit, Task, Bash(git:*), Bash(npm:*), Bash(pnpm:*), Bash(yarn:*), Bash(cargo:*), Bash(go:*), Bash(pytest:*), Bash(python:*), Bash(mvn:*), Bash(gradle:*), Bash(node:*) +model: opus +--- + +# Perf Orchestrator + +You coordinate the full `/perf` workflow. You MUST follow `docs/perf-requirements.md` as the canonical contract. + +## Non-Negotiable Rules (Repeat Every Phase) + +1. Sequential benchmarks only (never parallel) +2. Minimum duration: 60s (30s only for binary search) +3. One change at a time; revert between runs +4. Narrow-first; expand only with explicit approval +5. Verify everything; re-run anomalies +6. Clean baseline before each experiment +7. Resource minimalism +8. Check git history before hypotheses/changes +9. Clarify terminology before acting +10. Checkpoint commit + investigation log after each phase + +## Required Phases + +1) Setup & clarification +2) Baseline establishment +3) Breaking point discovery (binary search) +4) Constraint testing (CPU/memory limits) +5) Hypothesis generation +6) Code path analysis +7) Profiling (CPU/memory/JFR/perf) +8) Optimization & validation +9) Decision points (abandon/continue) +10) Consolidation + +## State & Artifacts + +All perf state is under `{state-dir}/perf/` where `state-dir = AI_STATE_DIR || .claude`: +- `investigation.json` +- `investigations/.md` +- `baselines/.json` + +Always update the investigation state and log after every phase. + +## Workflow Outline + +1. **Setup**: Confirm scenario, success metrics, and benchmark command. If unclear, ask the user. +2. **Baseline**: Run the baseline benchmark (60s min) and store results (validate baseline schema). +3. **Breaking Point**: Binary search with 30s runs to find failure threshold. +4. **Constraints**: Run CPU/memory constrained benchmarks; compare to baseline. +5. **Hypotheses**: Call `perf-theory-gatherer` (git history first). +6. **Code Paths**: Identify hotspots via repo-map or grep; document. +7. **Profiling**: Run profiler skill; capture evidence and file:line hotspots. Prefer built-in runtime tools (Node `--cpu-prof`, Java JFR, Python cProfile, Go pprof, Rust perf). +8. **Optimization**: Apply one change per experiment, validate with 2+ runs. +9. **Decision**: If no meaningful improvement, document and recommend pause/stop. +10. **Consolidation**: Write a single baseline per version (validate investigation + baseline schemas). + +## Tools & Delegation + +Use subagents/skills for focused work: + +- `perf:perf-theory-gatherer` for hypotheses +- `perf:perf-code-paths` agent for code-path discovery +- `perf:perf-theory-tester` for controlled experiments +- `perf:perf-profiler` skill for profiling +- `perf:perf-benchmarker` skill for benchmark runs +- `perf:perf-baseline-manager` skill for baseline management +- `perf:perf-investigation-logger` for structured logs +- `perf:perf-analyzer` for synthesis recommendations + +## Phase Execution Checklist + +For EACH phase: + +1. Execute phase-specific actions below +2. Update investigation state +3. Append phase log entry +4. Run checkpoint commit (unless explicitly blocked) + +If a phase cannot proceed, explain why and request only the minimum missing info. + +## Setup Phase (Implementation Guidance) + +```javascript +const pluginRoot = (process.env.PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || process.cwd()).replace(/\\/g, '/'); +const investigationState = require(`${pluginRoot}/lib/perf/investigation-state.js`); + +// Ask for missing scenario, metrics, success criteria, benchmark command, version +// Update investigation state with scenario + benchmark command metadata +``` + +## Baseline Phase (Implementation Guidance) + +Use the perf helpers to store baseline data and log evidence: + +```javascript +const pluginRoot = (process.env.PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || process.cwd()).replace(/\\/g, '/'); +const investigationState = require(`${pluginRoot}/lib/perf/investigation-state.js`); +const baselineStore = require(`${pluginRoot}/lib/perf/baseline-store.js`); + +// 1) Ask user for benchmark command + version if missing +// 2) Run perf-benchmarker skill (sequential, 60s min) +// 3) Write baseline +baselineStore.writeBaseline(version, { + command, + metrics, + env: envMetadata +}, process.cwd()); + +// 4) Log baseline evidence +const baselinePath = baselineStore.getBaselinePath(version, process.cwd()); +investigationState.appendBaselineLog({ + id: state.id, + userQuote, + command, + metrics, + baselinePath, + scenarios: state.scenario?.scenarios +}, process.cwd()); +``` + +## Breaking-Point Phase (Implementation Guidance) + +```javascript +const pluginRoot = (process.env.PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || process.cwd()).replace(/\\/g, '/'); +const investigationState = require(`${pluginRoot}/lib/perf/investigation-state.js`); +const breakingPointRunner = require(`${pluginRoot}/lib/perf/breaking-point-runner.js`); + +// Example assumes benchmark accepts a numeric parameter via PERF_PARAM_VALUE env var. +// Use scenario params to set min/max. +const result = await breakingPointRunner.runBreakingPointSearch({ + command, + paramEnv: 'PERF_PARAM_VALUE', + min: 1, + max: 500 +}); + +investigationState.updateInvestigation({ + breakingPoint: result.breakingPoint, + breakingPointHistory: result.history +}, process.cwd()); +``` + +## Constraint Phase (Implementation Guidance) + +```javascript +const pluginRoot = (process.env.PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || process.cwd()).replace(/\\/g, '/'); +const investigationState = require(`${pluginRoot}/lib/perf/investigation-state.js`); +const constraintRunner = require(`${pluginRoot}/lib/perf/constraint-runner.js`); + +const constraints = { cpu: '1', memory: '1GB' }; +const results = constraintRunner.runConstraintTest({ + command, + constraints +}); + +const state = investigationState.readInvestigation(process.cwd()); +const nextResults = Array.isArray(state.constraintResults) ? state.constraintResults : []; +nextResults.push(results); + +investigationState.updateInvestigation({ + constraintResults: nextResults +}, process.cwd()); +``` + +## Profiling Phase (Implementation Guidance) + +```javascript +const pluginRoot = (process.env.PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || process.cwd()).replace(/\\/g, '/'); +const investigationState = require(`${pluginRoot}/lib/perf/investigation-state.js`); +const profilingRunner = require(`${pluginRoot}/lib/perf/profiling-runner.js`); +const checkpoint = require(`${pluginRoot}/lib/perf/checkpoint.js`); + +const result = profilingRunner.runProfiling({ repoPath: process.cwd() }); +if (!result.ok) { + console.log(`Profiling failed: ${result.error}`); +} else { + const state = investigationState.readInvestigation(process.cwd()); + const nextResults = Array.isArray(state.profilingResults) ? state.profilingResults : []; + nextResults.push(result.result); + investigationState.updateInvestigation({ profilingResults: nextResults }, process.cwd()); + + investigationState.appendProfilingLog({ + id: state.id, + userQuote, + tool: result.result.tool, + command: result.result.command, + artifacts: result.result.artifacts, + hotspots: result.result.hotspots + }, process.cwd()); + +checkpoint.commitCheckpoint({ + phase: 'profiling', + id: state.id, + baselineVersion: baselineVersion || 'n/a', + deltaSummary: deltaSummary || 'n/a' +}); +} +``` + +## Optimization Phase (Implementation Guidance) + +```javascript +const pluginRoot = (process.env.PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || process.cwd()).replace(/\\/g, '/'); +const optimizationRunner = require(`${pluginRoot}/lib/perf/optimization-runner.js`); + +const result = optimizationRunner.runOptimizationExperiment({ + command, + changeSummary +}); + +// Append to investigation state + log via perf-investigation-logger +// Revert to baseline after each experiment +``` + +## Decision Phase (Implementation Guidance) + +```javascript +const pluginRoot = (process.env.PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || process.cwd()).replace(/\\/g, '/'); +const investigationState = require(`${pluginRoot}/lib/perf/investigation-state.js`); +const checkpoint = require(`${pluginRoot}/lib/perf/checkpoint.js`); + +const decision = { + verdict, + rationale +}; + +investigationState.updateInvestigation({ decision }, process.cwd()); +investigationState.appendDecisionLog({ + id: state.id, + userQuote, + verdict, + rationale +}, process.cwd()); + +checkpoint.commitCheckpoint({ + phase: 'decision', + id: state.id, + baselineVersion: baselineVersion || 'n/a', + deltaSummary: deltaSummary || 'n/a' +}); +``` + +## Consolidation Phase (Implementation Guidance) + +```javascript +const pluginRoot = (process.env.PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || process.cwd()).replace(/\\/g, '/'); +const consolidation = require(`${pluginRoot}/lib/perf/consolidation.js`); +const investigationState = require(`${pluginRoot}/lib/perf/investigation-state.js`); +const checkpoint = require(`${pluginRoot}/lib/perf/checkpoint.js`); + +const result = consolidation.consolidateBaseline({ + version, + baseline +}); + +investigationState.appendConsolidationLog({ + id: state.id, + userQuote, + version, + path: result.path +}, process.cwd()); + +checkpoint.commitCheckpoint({ + phase: 'consolidation', + id: state.id, + baselineVersion: version, + deltaSummary: deltaSummary || 'n/a' +}); +``` + +## Checkpoint Phase (Implementation Guidance) + +Invoke after EVERY phase once the investigation log is updated. + +```javascript +const pluginRoot = (process.env.PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || process.cwd()).replace(/\\/g, '/'); +const checkpoint = require(`${pluginRoot}/lib/perf/checkpoint.js`); + +const result = checkpoint.commitCheckpoint({ + phase: state.phase, + id: state.id, + baselineVersion: baselineVersion || 'n/a', + deltaSummary: deltaSummary || 'n/a' +}); + +if (!result.ok) { + console.log(`Checkpoint skipped: ${result.reason}`); +} +``` + +## Output Format + +Return a concise phase summary and next action: + +``` +phase: +status: in_progress|blocked|complete +baseline: +findings: [short bullets] +next: +``` + +## Critical Constraints (Repeat) + +- No parallel benchmarks. +- No short runs except binary search. +- One change at a time; revert between experiments. +- Always checkpoint + log after each phase. diff --git a/plugins/perf/agents/perf-theory-gatherer.md b/plugins/perf/agents/perf-theory-gatherer.md new file mode 100644 index 00000000..149aea7b --- /dev/null +++ b/plugins/perf/agents/perf-theory-gatherer.md @@ -0,0 +1,12 @@ +--- +name: perf-theory-gatherer +description: Generate top performance hypotheses after reviewing git history and current metrics. +tools: Read, Bash(git:*), Bash(node:*), Bash(npm:*), Bash(pnpm:*), Bash(yarn:*), Bash(cargo:*), Bash(go:*), Bash(pytest:*), Bash(python:*), Bash(mvn:*), Bash(gradle:*) +model: opus +--- + +# Perf Theory Gatherer + +Generate hypotheses for performance bottlenecks and regressions. You MUST read `docs/perf-requirements.md` before outputting hypotheses. + +You MUST execute the perf-theory-gatherer skill to produce hypotheses. Do not bypass the skill. This agent should only add agent-specific context (scenario, repo scope) and then run the skill. diff --git a/plugins/perf/agents/perf-theory-tester.md b/plugins/perf/agents/perf-theory-tester.md new file mode 100644 index 00000000..c7c30c2b --- /dev/null +++ b/plugins/perf/agents/perf-theory-tester.md @@ -0,0 +1,43 @@ +--- +name: perf-theory-tester +description: Execute controlled perf experiments, one change at a time, with rollback between runs. +tools: Read, Write, Edit, Bash(git:*), Bash(npm:*), Bash(pnpm:*), Bash(yarn:*), Bash(cargo:*), Bash(go:*), Bash(pytest:*), Bash(python:*), Bash(mvn:*), Bash(gradle:*), Bash(node:*) +model: opus +--- + +# Perf Theory Tester + +Test hypotheses using controlled experiments. You MUST follow `docs/perf-requirements.md`. + +You MUST execute the perf-theory-tester skill to produce the output. Do not bypass the skill. + +## Rules + +- One change per experiment. +- Revert to baseline between experiments. +- Run each experiment at least twice. +- Benchmarks must be sequential and ≥60s (30s only for binary search). + +## Workflow + +1. Check out clean baseline (`git status` must be clean). +2. Apply single change for the experiment. +3. Run benchmark twice. +4. Record metrics + variance. +5. Revert change and confirm clean state. + +## Output Format + +``` +experiment: +change: +baseline: +experiment: +delta: +verdict: supports|refutes|inconclusive +``` + +## Constraints + +- Do NOT stack multiple changes. +- If results conflict, re-run and mark inconclusive. diff --git a/plugins/perf/commands/perf.md b/plugins/perf/commands/perf.md new file mode 100644 index 00000000..45f9a287 --- /dev/null +++ b/plugins/perf/commands/perf.md @@ -0,0 +1,425 @@ +--- +description: Structured performance investigation with baselines, profiling, and evidence-backed decisions +argument-hint: "[--resume] [--phase setup|baseline|breaking-point|constraints|hypotheses|code-paths|profiling|optimization|decision|consolidation] [--id ] [--scenario ] [--command ] [--version ] [--quote ] [--hypotheses-file ] [--param-env ] [--param-min ] [--param-max ] [--cpu ] [--memory ] [--change ] [--verdict ] [--rationale ]" +allowed-tools: Read, Write, Edit, Task, Bash(git:*), Bash(node:*), Bash(npm:*), Bash(pnpm:*), Bash(yarn:*), Bash(cargo:*), Bash(go:*), Bash(pytest:*), Bash(mvn:*), Bash(gradle:*) +--- + +# /perf - Performance Investigation Workflow + +Run a rigorous, evidence-driven performance investigation with strict rules, baselines, and reproducible benchmarks. + +## Canonical Requirements + +All behavior must follow: +- `docs/perf-requirements.md` (source of truth) +- `docs/perf-research-methodology.md` + +## Arguments + +- `--resume`: Continue the latest investigation from `{state-dir}/perf/investigation.json` +- `--phase `: Force starting phase (use only when resuming) +- `--id `: Set investigation id (new only) +- `--scenario `: Short scenario description +- `--command `: Benchmark command (prints PERF_METRICS markers) +- `--version `: Baseline version label +- `--quote `: User quote to record in logs +- `--hypotheses-file `: JSON file with hypothesis list (for hypotheses phase) +- `--param-env `: Env var for breaking-point value (default PERF_PARAM_VALUE) +- `--param-min `: Breaking-point min value (default 1) +- `--param-max `: Breaking-point max value (default 500) +- `--cpu `: Constraint CPU limit (default 1) +- `--memory `: Constraint memory limit (default 1GB) +- `--change `: Optimization change summary +- `--verdict `: Decision verdict +- `--rationale `: Decision rationale + +## Phase 1: Initialize Investigation State + +```javascript +const pluginRoot = (process.env.PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || process.cwd()).replace(/\\/g, '/'); +const investigationState = require(`${pluginRoot}/lib/perf/investigation-state.js`); +const baselineStore = require(`${pluginRoot}/lib/perf/baseline-store.js`); +const benchmarkRunner = require(`${pluginRoot}/lib/perf/benchmark-runner.js`); +const breakingPointRunner = require(`${pluginRoot}/lib/perf/breaking-point-runner.js`); +const constraintRunner = require(`${pluginRoot}/lib/perf/constraint-runner.js`); +const profilingRunner = require(`${pluginRoot}/lib/perf/profiling-runner.js`); +const optimizationRunner = require(`${pluginRoot}/lib/perf/optimization-runner.js`); +const consolidation = require(`${pluginRoot}/lib/perf/consolidation.js`); +const checkpoint = require(`${pluginRoot}/lib/perf/checkpoint.js`); +const argumentParser = require(`${pluginRoot}/lib/perf/argument-parser.js`); +const codePaths = require(`${pluginRoot}/lib/perf/code-paths.js`); +const repoMap = require(`${pluginRoot}/lib/repo-map`); +const fs = require('fs'); + +const args = argumentParser.parseArguments('$ARGUMENTS'); +const options = { + resume: false, + phase: null, + id: null, + scenario: '', + command: '', + version: '', + quote: '', + hypothesesFile: '', + paramEnv: 'PERF_PARAM_VALUE', + paramMin: 1, + paramMax: 500, + cpu: '1', + memory: '1GB', + change: '', + verdict: '', + rationale: '' +}; + +for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--resume') options.resume = true; + else if (arg === '--phase' && args[i + 1]) options.phase = args[++i]; + else if (arg === '--id' && args[i + 1]) options.id = args[++i]; + else if (arg === '--scenario' && args[i + 1]) options.scenario = args[++i]; + else if (arg === '--command' && args[i + 1]) options.command = args[++i]; + else if (arg === '--version' && args[i + 1]) options.version = args[++i]; + else if (arg === '--quote' && args[i + 1]) options.quote = args[++i]; + else if (arg === '--hypotheses-file' && args[i + 1]) options.hypothesesFile = args[++i]; + else if (arg === '--param-env' && args[i + 1]) options.paramEnv = args[++i]; + else if (arg === '--param-min' && args[i + 1]) options.paramMin = Number(args[++i]); + else if (arg === '--param-max' && args[i + 1]) options.paramMax = Number(args[++i]); + else if (arg === '--cpu' && args[i + 1]) options.cpu = args[++i]; + else if (arg === '--memory' && args[i + 1]) options.memory = args[++i]; + else if (arg === '--change' && args[i + 1]) options.change = args[++i]; + else if (arg === '--verdict' && args[i + 1]) options.verdict = args[++i]; + else if (arg === '--rationale' && args[i + 1]) options.rationale = args[++i]; +} + +const cwd = process.cwd(); +const allowedPhases = investigationState.PHASES; +if (options.phase && !allowedPhases.includes(options.phase)) { + console.error(`Invalid phase: ${options.phase}. Allowed: ${allowedPhases.join(', ')}`); + process.exit(1); +} + +let state = investigationState.readInvestigation(cwd); +if (options.resume) { + if (!state) { + console.error('No active investigation found. Run /perf without --resume first.'); + process.exit(1); + } +} else { + state = investigationState.initializeInvestigation({ + id: options.id, + phase: options.phase, + scenario: options.scenario + }, cwd); +} + +if (options.phase && options.resume) { + state = investigationState.updateInvestigation({ phase: options.phase }, cwd); +} + +const userQuote = options.quote || options.scenario || 'n/a'; +if (options.command || options.version || options.scenario) { + state = investigationState.updateInvestigation({ + scenario: { + description: options.scenario || state.scenario?.description || '', + metrics: state.scenario?.metrics || [], + successCriteria: state.scenario?.successCriteria || '', + scenarios: state.scenario?.scenarios || [] + }, + benchmark: { + command: options.command || state.benchmark?.command || '', + version: options.version || state.benchmark?.version || '' + } + }, cwd); +} + +console.log(` +## /perf Investigation + +**ID**: ${state.id} +**Phase**: ${state.phase} +**Scenario**: ${state.scenario?.description || 'n/a'} + +Running phase handler... +`); + +const phase = state.phase; +const command = state.benchmark?.command || options.command; +const version = state.benchmark?.version || options.version; + +function requireFields(fields) { + const missing = fields.filter(Boolean); + if (missing.length > 0) { + console.error(`Missing required input(s): ${missing.join(', ')}`); + process.exit(1); + } +} + +const phaseRequirements = { + setup: () => requireFields([ + state.scenario?.description ? '' : '--scenario', + command ? '' : '--command', + version ? '' : '--version' + ]), + baseline: () => requireFields([command ? '' : '--command', version ? '' : '--version']), + 'breaking-point': () => requireFields([command ? '' : '--command']), + constraints: () => requireFields([command ? '' : '--command']), + hypotheses: () => requireFields([state.scenario?.description ? '' : '--scenario']), + 'code-paths': () => requireFields([state.scenario?.description ? '' : '--scenario']), + profiling: () => requireFields([]), + optimization: () => requireFields([options.change ? '' : '--change']), + decision: () => requireFields([options.verdict ? '' : '--verdict', options.rationale ? '' : '--rationale']), + consolidation: () => requireFields([version ? '' : '--version']) +}; + +async function runPhase() { + if (phaseRequirements[phase]) { + phaseRequirements[phase](); + } + switch (phase) { + case 'setup': { + state = investigationState.updateInvestigation({ phase: 'baseline' }, cwd); + investigationState.appendSetupLog({ + id: state.id, + userQuote, + scenario: state.scenario?.description || '', + command, + version + }, cwd); + checkpoint.commitCheckpoint({ + phase: 'setup', + id: state.id, + baselineVersion: version, + deltaSummary: 'n/a' + }); + return; + } + case 'baseline': { + const result = benchmarkRunner.runBenchmark(command); + const parsed = benchmarkRunner.parseMetrics(result.output); + if (!parsed.ok) { + console.error(`Baseline metrics parse failed: ${parsed.error}`); + process.exit(1); + } + baselineStore.writeBaseline(version, { command, metrics: parsed.metrics }, cwd); + const baselinePath = baselineStore.getBaselinePath(version, cwd); + investigationState.appendBaselineLog({ + id: state.id, + userQuote, + command, + metrics: parsed.metrics, + baselinePath, + scenarios: state.scenario?.scenarios + }, cwd); + checkpoint.commitCheckpoint({ + phase: 'baseline', + id: state.id, + baselineVersion: version, + deltaSummary: 'n/a' + }); + state = investigationState.updateInvestigation({ phase: 'breaking-point' }, cwd); + return; + } + case 'breaking-point': { + const result = await breakingPointRunner.runBreakingPointSearch({ + command, + paramEnv: options.paramEnv, + min: options.paramMin, + max: options.paramMax + }); + investigationState.updateInvestigation({ + breakingPoint: result.breakingPoint, + breakingPointHistory: result.history, + phase: 'constraints' + }, cwd); + investigationState.appendBreakingPointLog({ + id: state.id, + userQuote, + paramEnv: options.paramEnv, + min: options.paramMin, + max: options.paramMax, + breakingPoint: result.breakingPoint + }, cwd); + checkpoint.commitCheckpoint({ + phase: 'breaking-point', + id: state.id, + baselineVersion: version || 'n/a', + deltaSummary: `breakingPoint=${result.breakingPoint ?? 'n/a'}` + }); + return; + } + case 'constraints': { + const results = constraintRunner.runConstraintTest({ + command, + constraints: { cpu: options.cpu, memory: options.memory } + }); + const nextResults = Array.isArray(state.constraintResults) ? state.constraintResults : []; + nextResults.push(results); + investigationState.updateInvestigation({ constraintResults: nextResults, phase: 'hypotheses' }, cwd); + investigationState.appendConstraintLog({ + id: state.id, + userQuote, + constraints: results.constraints, + delta: results.delta + }, cwd); + checkpoint.commitCheckpoint({ + phase: 'constraints', + id: state.id, + baselineVersion: version || 'n/a', + deltaSummary: 'constraints' + }); + return; + } + case 'hypotheses': { + let hypotheses = Array.isArray(state.hypotheses) ? state.hypotheses : []; + if (hypotheses.length === 0) { + if (!options.hypothesesFile) { + console.error('Missing hypotheses. Run perf-theory-gatherer or provide --hypotheses-file.'); + process.exit(1); + } + try { + const raw = fs.readFileSync(options.hypothesesFile, 'utf8'); + const parsed = JSON.parse(raw); + hypotheses = Array.isArray(parsed.hypotheses) ? parsed.hypotheses : parsed; + } catch (error) { + console.error(`Failed to load hypotheses file: ${error.message}`); + process.exit(1); + } + } + investigationState.updateInvestigation({ hypotheses, phase: 'code-paths' }, cwd); + investigationState.appendHypothesesLog({ + id: state.id, + userQuote, + hypotheses + }, cwd); + checkpoint.commitCheckpoint({ + phase: 'hypotheses', + id: state.id, + baselineVersion: version || 'n/a', + deltaSummary: 'n/a' + }); + return; + } + case 'code-paths': { + const mapStatus = repoMap.status(cwd); + if (!mapStatus.exists) { + console.log('Repo map not found. Run /repo-map init for better code-path coverage.'); + } + const map = repoMap.load(cwd); + const result = codePaths.collectCodePaths(map, state.scenario?.description || ''); + investigationState.updateInvestigation({ codePaths: result.paths, phase: 'profiling' }, cwd); + investigationState.appendCodePathsLog({ + id: state.id, + userQuote, + keywords: result.keywords, + paths: result.paths + }, cwd); + checkpoint.commitCheckpoint({ + phase: 'code-paths', + id: state.id, + baselineVersion: version || 'n/a', + deltaSummary: `paths=${result.paths.length}` + }); + return; + } + case 'profiling': { + const result = profilingRunner.runProfiling({ repoPath: cwd }); + if (!result.ok) { + console.error(`Profiling failed: ${result.error}`); + process.exit(1); + } + const nextResults = Array.isArray(state.profilingResults) ? state.profilingResults : []; + nextResults.push(result.result); + investigationState.updateInvestigation({ profilingResults: nextResults, phase: 'optimization' }, cwd); + investigationState.appendProfilingLog({ + id: state.id, + userQuote, + tool: result.result.tool, + command: result.result.command, + artifacts: result.result.artifacts, + hotspots: result.result.hotspots + }, cwd); + checkpoint.commitCheckpoint({ + phase: 'profiling', + id: state.id, + baselineVersion: version || 'n/a', + deltaSummary: 'n/a' + }); + return; + } + case 'optimization': { + const result = optimizationRunner.runOptimizationExperiment({ + command, + changeSummary: options.change + }); + const nextResults = Array.isArray(state.results) ? state.results : []; + nextResults.push(result); + investigationState.updateInvestigation({ results: nextResults, phase: 'decision' }, cwd); + investigationState.appendOptimizationLog({ + id: state.id, + userQuote, + change: options.change, + delta: result.delta, + verdict: result.verdict + }, cwd); + checkpoint.commitCheckpoint({ + phase: 'optimization', + id: state.id, + baselineVersion: version || 'n/a', + deltaSummary: 'n/a' + }); + return; + } + case 'decision': { + const decision = { verdict: options.verdict, rationale: options.rationale }; + investigationState.updateInvestigation({ decision, phase: 'consolidation' }, cwd); + investigationState.appendDecisionLog({ + id: state.id, + userQuote, + verdict: options.verdict, + rationale: options.rationale + }, cwd); + checkpoint.commitCheckpoint({ + phase: 'decision', + id: state.id, + baselineVersion: version || 'n/a', + deltaSummary: 'n/a' + }); + return; + } + case 'consolidation': { + const baseline = baselineStore.readBaseline(version, cwd); + if (!baseline) { + console.error(`Baseline not found for version ${version}`); + process.exit(1); + } + const result = consolidation.consolidateBaseline({ version, baseline }, cwd); + investigationState.appendConsolidationLog({ + id: state.id, + userQuote, + version, + path: result.path + }, cwd); + checkpoint.commitCheckpoint({ + phase: 'consolidation', + id: state.id, + baselineVersion: version, + deltaSummary: 'n/a' + }); + investigationState.updateInvestigation({ phase: 'complete' }, cwd); + return; + } + default: + return; + } +} + +await runPhase(); +``` + +## Output + +- Updated `{state-dir}/perf/investigation.json` +- Investigation log at `{state-dir}/perf/investigations/.md` +- Baseline files at `{state-dir}/perf/baselines/.json` + +Begin the performance investigation now. diff --git a/plugins/perf/hooks/checkpoint.md b/plugins/perf/hooks/checkpoint.md new file mode 100644 index 00000000..b2237298 --- /dev/null +++ b/plugins/perf/hooks/checkpoint.md @@ -0,0 +1,27 @@ +--- +name: perf-checkpoint +description: Create a checkpoint commit and update the investigation log after each phase. +--- + +# Perf Checkpoint Hook + +Create a checkpoint commit and update `{state-dir}/perf/investigations/.md` after each phase. + +Follow `docs/perf-requirements.md` as the canonical contract. + +## Commit Message Format + +``` +perf: phase [] baseline= delta= +``` + +## Required Steps + +1. Ensure working tree is clean aside from intended changes. +2. Update investigation log with phase summary and evidence. +3. Commit with the format above. + +## Constraints + +- No checkpoint if benchmarks are still running. +- Do not batch multiple phases into one commit. diff --git a/plugins/perf/hooks/constraint-tester.md b/plugins/perf/hooks/constraint-tester.md new file mode 100644 index 00000000..c4084ff8 --- /dev/null +++ b/plugins/perf/hooks/constraint-tester.md @@ -0,0 +1,40 @@ +--- +name: perf-constraint-tester +description: Apply CPU/memory constraints and compare results to baseline. +--- + +# Perf Constraint Tester Hook + +Apply resource constraints and run the same benchmark sequentially. + +Follow `docs/perf-requirements.md` as the canonical contract. + +## Required Steps + +1. Set CPU limit and/or memory limit (document exact values). +2. Run baseline benchmark (60s minimum). +3. Run constrained benchmark (60s minimum). +4. Compare metrics and record deltas. + +Constraints should be exposed to the benchmark via env vars: + +``` +PERF_CPU_LIMIT +PERF_MEMORY_LIMIT +``` + +## Output Format + +``` +constraints: + cpu: + memory: +baseline: +constrained: +delta: +``` + +## Constraints + +- Do not run constraints in parallel. +- Revert to unconstrained state afterward. diff --git a/plugins/perf/lib/config/index.js b/plugins/perf/lib/config/index.js new file mode 100644 index 00000000..25bffeb2 --- /dev/null +++ b/plugins/perf/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/perf/lib/cross-platform/RESEARCH.md b/plugins/perf/lib/cross-platform/RESEARCH.md new file mode 100644 index 00000000..2fbecc27 --- /dev/null +++ b/plugins/perf/lib/cross-platform/RESEARCH.md @@ -0,0 +1,274 @@ +# Cross-Platform Research: Claude Code, OpenCode, Codex CLI + +Research compiled from official documentation and best practices guides. +This document informs the implementation in `index.js`. + +## Sources + +### Official Documentation +- [MCP Specification 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25) +- [OpenCode CLI Documentation](https://opencode.ai/docs/) +- [Codex CLI Documentation](https://developers.openai.com/codex/) +- [Anthropic Claude 4 Best Practices](https://docs.claude.com/en/docs/build-with-claude/prompt-engineering/claude-4-best-practices) + +### Community Guides +- [MCP Error Handling Best Practices](https://mcpcat.io/guides/error-handling-custom-mcp-servers/) +- [Claude Code Token Optimization](https://medium.com/@joe.njenga/claude-code-just-cut-mcp-context-bloat-by-46-9) +- [Agent Skills Specification](https://agentskills.io/) + +--- + +## 1. Platform Comparison + +| Feature | Claude Code | OpenCode | Codex CLI | +|---------|-------------|----------|-----------| +| MCP Config | `.mcp.json` | `opencode.json` | `config.toml` | +| State Dir | `.claude/` | `.opencode/` | `.codex/` | +| Instructions | `CLAUDE.md` | `AGENTS.md` | `AGENTS.md` | +| Command Prefix | `/` | `/` | `$` | +| Skill Format | Plugin `.md` | Command `.md` | `SKILL.md` | + +--- + +## 2. MCP Server Best Practices + +### Tool Schema Design + +```javascript +// Good: Concise, flat, with enums +{ + name: 'task_discover', + description: 'Find tasks from configured sources', + inputSchema: { + type: 'object', + properties: { + source: { type: 'string', enum: ['gh-issues', 'linear', 'tasks-md'] }, + limit: { type: 'number', description: 'Max tasks (default: 10)' } + } + } +} + +// Bad: Verbose, nested +{ + name: 'discover_tasks_from_multiple_sources_with_filtering', + description: 'This tool allows you to discover tasks from various sources...', + inputSchema: { + type: 'object', + properties: { + config: { + type: 'object', + properties: { + sources: { type: 'object', properties: { ... } } + } + } + } + } +} +``` + +### Error Handling + +Use `isError: true` for application errors, NOT JSON-RPC error codes: + +```javascript +// Correct +return { + content: [{ type: 'text', text: 'Error: GitHub CLI not found. Install: brew install gh' }], + isError: true +}; + +// Wrong - don't throw +throw new Error('GitHub CLI not found'); +``` + +### Token Efficiency + +Research shows MCP tools can consume 50K+ tokens before conversations start. + +Optimization strategies: +1. **Concise descriptions**: <100 chars +2. **Tool consolidation**: One tool with filter vs many similar tools +3. **Minimal responses**: JSON, not verbose text +4. **Defer loading**: Load tools on-demand (Claude Code 2025+) + +--- + +## 3. Prompt Formatting + +### Cross-Model Compatibility + +| Model | Preference | Notes | +|-------|------------|-------| +| Claude | XML tags | Trained with XML, follows literally | +| GPT-4 | Markdown | Flexible interpretation | +| Both | Headers + XML | Best compatibility | + +### Recommended Format + +```markdown +## Section Title + + +Data block with XML tags for Claude compatibility + + +1. Numbered instructions +2. Clear steps + +**Critical constraint repeated for emphasis** +``` + +### Lost in the Middle + +Both Claude and GPT-4 recall information better from the START and END of prompts. + +**Solution**: Put critical constraints at both locations. + +--- + +## 4. State Management + +### File Locations + +| File | Location | Purpose | +|------|----------|---------| +| `flow.json` | `{state-dir}/` | Workflow phase, task, policy | +| `tasks.json` | `{state-dir}/` | Active worktree/task | +| `sources/preference.json` | `{state-dir}/` | Cached source preference | + +### Design Principles + +Keep state **simple and flat**: +- No history arrays (they grow unbounded) +- No nested objects (hard to update) +- No cached settings (stale quickly) + +```javascript +// Good +{ "phase": "implementation", "task": { "id": "123" } } + +// Bad +{ "history": [...hundreds of entries], "cache": { "settings": {...} } } +``` + +--- + +## 5. Tool Calling Differences + +### Claude +- Higher accuracy (100% vs 81% in benchmarks) +- Interleaved thinking with tool use +- Proactive tool calling + +### GPT-4 +- More robust API (fewer errors) +- Better with complex parameters +- Lower cost per task + +### Cross-Platform Recommendations + +1. Define schemas explicitly +2. Include examples of valid calls +3. List tools explicitly with fallback message +4. Use flat parameter structures + +--- + +## 6. Agent Prompt Template + +```markdown +# Agent: {name} + +## Role +{one-sentence description} + +## Instructions +1. ALWAYS {critical constraint} +2. NEVER {prohibited action} +3. {specific step} + +## Tools Available +- tool_1: description +- tool_2: description +If tool not listed, respond: "Tool not available" + +## Output Format + +{exact structure} + + +## Critical Constraints +{repeat most important - addresses Lost in Middle} +``` + +--- + +## 7. Configuration Examples + +### OpenCode (`~/.config/opencode/opencode.json`) + +```json +{ + "mcp": { + "awesome-slash": { + "type": "local", + "command": ["node", "/path/to/mcp-server/index.js"], + "environment": { + "PLUGIN_ROOT": "/path/to/plugin", + "AI_STATE_DIR": ".opencode" + }, + "timeout": 10000, + "enabled": true + } + } +} +``` + +### Codex CLI (`~/.codex/config.toml`) + +```toml +[mcp_servers.awesome-slash] +command = "node" +args = ["/path/to/mcp-server/index.js"] +env = { PLUGIN_ROOT = "/path/to/plugin", AI_STATE_DIR = ".codex" } +enabled = true +``` + +### Claude Code (plugin system) + +Uses marketplace installation or manual plugin setup. + +--- + +## 8. Skills/Commands + +### Codex SKILL.md Format + +```yaml +--- +name: skill-name +description: Description for implicit invocation (max 500 chars) +--- +Skill instructions here. +``` + +### OpenCode Command Format + +```yaml +--- +description: Command description +agent: optional-agent +--- +Prompt with $ARGUMENTS placeholder. +``` + +--- + +## Key Takeaways + +1. **Use AI_STATE_DIR env var** for platform-aware state directories +2. **Keep tool descriptions under 100 chars** for token efficiency +3. **Return structured JSON** not verbose text +4. **Use isError: true** for application errors +5. **Put critical info at START and END** of prompts +6. **Test across platforms** - behavior differs diff --git a/plugins/perf/lib/cross-platform/index.js b/plugins/perf/lib/cross-platform/index.js new file mode 100644 index 00000000..97dbde76 --- /dev/null +++ b/plugins/perf/lib/cross-platform/index.js @@ -0,0 +1,462 @@ +/** + * Cross-Platform Best Practices Library + * + * Patterns and utilities for building tools that work across: + * - Claude Code (Anthropic) + * - OpenCode (multi-model) + * - Codex CLI (OpenAI) + * + * Based on research from official documentation: + * - Anthropic Claude 4 Best Practices + * - OpenCode CLI Documentation + * - Codex CLI Skills and MCP Integration + * - MCP Specification (2025-11-25) + * + * @module cross-platform + */ + +const path = require('path'); +const fs = require('fs'); + +/** + * Platform detection and configuration + */ +const PLATFORMS = { + CLAUDE_CODE: 'claude-code', + OPENCODE: 'opencode', + CODEX_CLI: 'codex-cli' +}; + +/** + * State directory by platform + * Each platform uses its own directory to avoid conflicts + */ +const STATE_DIRS = { + [PLATFORMS.CLAUDE_CODE]: '.claude', + [PLATFORMS.OPENCODE]: '.opencode', + [PLATFORMS.CODEX_CLI]: '.codex' +}; + +/** + * Get the state directory for the current platform + * Uses AI_STATE_DIR env var if set, otherwise defaults to .claude + * + * @returns {string} State directory name + */ +function getStateDir() { + return process.env.AI_STATE_DIR || STATE_DIRS[PLATFORMS.CLAUDE_CODE]; +} + +/** + * Detect current platform from environment + * + * @returns {string} Platform identifier + */ +function detectPlatform() { + const stateDir = process.env.AI_STATE_DIR; + if (stateDir === '.opencode') return PLATFORMS.OPENCODE; + if (stateDir === '.codex') return PLATFORMS.CODEX_CLI; + return PLATFORMS.CLAUDE_CODE; +} + +/** + * MCP Tool Schema Best Practices + * + * Guidelines for cross-platform tool definitions: + * 1. Use descriptive, semantic names (workflow_status not ws) + * 2. Keep descriptions concise (<100 chars) for token efficiency + * 3. Use flat parameter structures when possible + * 4. Include enums for constrained values + * 5. Make parameters optional with sensible defaults + */ +const TOOL_SCHEMA_GUIDELINES = { + // Max description length for token efficiency + maxDescriptionLength: 100, + + // Naming conventions + namingPattern: /^[a-z][a-z0-9_]*$/, + + // Parameter best practices + preferFlatStructures: true, + useEnumsForConstraints: true, + documentDefaults: true +}; + +/** + * Create a tool definition following cross-platform best practices + * + * @param {string} name - Tool name (snake_case) + * @param {string} description - Concise description + * @param {Object} properties - Input schema properties + * @param {string[]} required - Required property names + * @returns {Object} MCP-compatible tool definition + */ +function createToolDefinition(name, description, properties = {}, required = []) { + // Validate name + if (!TOOL_SCHEMA_GUIDELINES.namingPattern.test(name)) { + console.warn(`Tool name "${name}" should be snake_case`); + } + + // Warn if description too long + if (description.length > TOOL_SCHEMA_GUIDELINES.maxDescriptionLength) { + console.warn(`Tool "${name}" description exceeds ${TOOL_SCHEMA_GUIDELINES.maxDescriptionLength} chars`); + } + + return { + name, + description, + inputSchema: { + type: 'object', + properties, + required + } + }; +} + +/** + * Error Response Patterns + * + * MCP uses isError flag for application errors, NOT JSON-RPC error codes. + * Error messages should be actionable so AI can recover. + */ + +/** + * Create a success response + * + * @param {*} data - Response data (will be JSON stringified if object) + * @returns {Object} MCP content response + */ +function successResponse(data) { + const text = typeof data === 'object' ? JSON.stringify(data, null, 2) : String(data); + return { + content: [{ type: 'text', text }] + }; +} + +/** + * Create an error response with actionable message + * + * @param {string} message - Error message (should suggest recovery) + * @param {Object} details - Optional additional details + * @returns {Object} MCP error response + */ +function errorResponse(message, details = null) { + let text = `Error: ${message}`; + if (details) { + text += `\nDetails: ${JSON.stringify(details)}`; + } + return { + content: [{ type: 'text', text }], + isError: true + }; +} + +/** + * Create an error response for missing tool + * + * @param {string} name - Tool name that was requested + * @param {string[]} available - List of available tools + * @returns {Object} MCP error response + */ +function unknownToolResponse(name, available = []) { + let text = `Error: Unknown tool "${name}"`; + if (available.length > 0) { + text += `\nAvailable tools: ${available.join(', ')}`; + } + return { + content: [{ type: 'text', text }], + isError: true + }; +} + +/** + * Prompt Formatting for Cross-Model Compatibility + * + * Different models have different preferences: + * - Claude: Trained with XML tags, follows instructions literally + * - GPT-4: Prefers Markdown, more flexible interpretation + * + * For maximum compatibility, use both: + * - Markdown headers for major sections + * - XML tags for data blocks + */ + +/** + * Format structured data for cross-model prompts + * + * @param {string} tag - XML-style tag name + * @param {string} content - Content to wrap + * @returns {string} Formatted content block + */ +function formatBlock(tag, content) { + return `<${tag}>\n${content}\n`; +} + +/** + * Format a list of items for prompts + * + * @param {string[]} items - Items to format + * @param {boolean} numbered - Use numbered list + * @returns {string} Formatted list + */ +function formatList(items, numbered = false) { + return items.map((item, i) => { + const prefix = numbered ? `${i + 1}.` : '-'; + return `${prefix} ${item}`; + }).join('\n'); +} + +/** + * Create an agent prompt section with cross-model formatting + * + * @param {string} title - Section title + * @param {string} content - Section content + * @returns {string} Formatted section + */ +function formatSection(title, content) { + return `## ${title}\n\n${content}\n`; +} + +/** + * Token Efficiency Strategies + * + * Key insights from research: + * - MCP tools can consume 50K+ tokens before conversation starts + * - Concise descriptions reduce overhead by 60%+ + * - Consolidate similar tools (one tool with filter vs many tools) + * - Return minimal structured JSON, not verbose text + */ + +/** + * Truncate text to limit with ellipsis + * + * @param {string} text - Text to truncate + * @param {number} maxLength - Maximum length + * @returns {string} Truncated text + */ +function truncate(text, maxLength) { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength - 3) + '...'; +} + +/** + * Create a compact summary of findings/results + * + * @param {Array} items - Items to summarize + * @param {Function} keyFn - Function to extract key from item + * @param {number} maxItems - Maximum items to include + * @returns {Object} Compact summary + */ +function compactSummary(items, keyFn, maxItems = 10) { + const limited = items.slice(0, maxItems); + const truncated = items.length > maxItems; + + // Group by key + const groups = {}; + for (const item of limited) { + const key = keyFn(item); + groups[key] = (groups[key] || 0) + 1; + } + + return { + total: items.length, + showing: limited.length, + truncated, + byKey: groups + }; +} + +/** + * Agent Prompt Best Practices + * + * Cross-model recommendations: + * 1. State instructions explicitly - don't rely on inference + * 2. Put critical constraints at START and END (Lost in Middle) + * 3. Use imperative language: "Do X", "Never Y" + * 4. Include 2-3 examples for complex tasks + * 5. Explicit tool allowlisting + * 6. Flat state management - pass state each turn + */ + +/** + * Agent prompt template structure + */ +const AGENT_TEMPLATE = `# Agent: {name} + +## Role +{role} + +## Instructions +{instructions} + +## Tools Available +{tools} +If a tool is not listed above, respond with: "Tool not available" + +## Output Format +{outputFormat} + +## Critical Constraints +{constraints}`; + +/** + * Create an agent prompt from template + * + * @param {Object} config - Agent configuration + * @param {string} config.name - Agent name + * @param {string} config.role - One-sentence role description + * @param {string[]} config.instructions - Imperative instructions + * @param {Object[]} config.tools - Available tools {name, description} + * @param {string} config.outputFormat - Expected output format + * @param {string[]} config.constraints - Critical constraints (repeated for emphasis) + * @returns {string} Formatted agent prompt + */ +function createAgentPrompt(config) { + const { + name, + role, + instructions = [], + tools = [], + outputFormat = 'Respond with structured JSON', + constraints = [] + } = config; + + // Format instructions as numbered list + const instructionsList = instructions.map((inst, i) => `${i + 1}. ${inst}`).join('\n'); + + // Format tools + const toolsList = tools.map(t => `- ${t.name}: ${t.description}`).join('\n'); + + // Format constraints (repeated for "Lost in Middle" mitigation) + const constraintsList = constraints.map(c => `- **${c}**`).join('\n'); + + return AGENT_TEMPLATE + .replace('{name}', name) + .replace('{role}', role) + .replace('{instructions}', instructionsList) + .replace('{tools}', toolsList) + .replace('{outputFormat}', outputFormat) + .replace('{constraints}', constraintsList); +} + +/** + * Convert a path to forward slashes (safe for require() on all platforms) + * Windows paths with backslashes break in require() strings + * + * @param {string} p - Path to normalize + * @returns {string} Path with forward slashes + */ +function normalizePathForRequire(p) { + return p.replace(/\\/g, '/'); +} + +/** + * Platform-specific configuration helpers + */ + +/** + * Get OpenCode MCP configuration object + * + * @param {string} serverPath - Path to MCP server + * @param {Object} env - Environment variables + * @returns {Object} OpenCode config structure + */ +function getOpenCodeConfig(serverPath, env = {}) { + return { + mcp: { + 'awesome-slash': { + type: 'local', + command: ['node', serverPath], + environment: { + PLUGIN_ROOT: path.dirname(path.dirname(serverPath)), + AI_STATE_DIR: '.opencode', + ...env + }, + timeout: 10000, + enabled: true + } + } + }; +} + +/** + * Get Codex CLI MCP configuration (TOML format) + * + * @param {string} serverPath - Path to MCP server + * @param {Object} env - Environment variables + * @returns {string} TOML configuration + */ +function getCodexConfig(serverPath, env = {}) { + const envEntries = Object.entries({ + PLUGIN_ROOT: path.dirname(path.dirname(serverPath)), + AI_STATE_DIR: '.codex', + ...env + }).map(([k, v]) => `${k} = "${v}"`).join(', '); + + return ` +[mcp_servers.awesome-slash] +command = "node" +args = ["${serverPath}"] +env = { ${envEntries} } +enabled = true +`.trim(); +} + +/** + * Instruction file conventions by platform + */ +const INSTRUCTION_FILES = { + [PLATFORMS.CLAUDE_CODE]: ['CLAUDE.md', '.claude/CLAUDE.md'], + [PLATFORMS.OPENCODE]: ['AGENTS.md', 'CLAUDE.md'], + [PLATFORMS.CODEX_CLI]: ['AGENTS.md', 'AGENTS.override.md'] +}; + +/** + * Get instruction file paths for current platform + * + * @param {string} platform - Platform identifier + * @returns {string[]} Instruction file paths in precedence order + */ +function getInstructionFiles(platform = null) { + const p = platform || detectPlatform(); + return INSTRUCTION_FILES[p] || INSTRUCTION_FILES[PLATFORMS.CLAUDE_CODE]; +} + +module.exports = { + // Platform detection + PLATFORMS, + STATE_DIRS, + getStateDir, + detectPlatform, + + // Tool schema + TOOL_SCHEMA_GUIDELINES, + createToolDefinition, + + // Response helpers + successResponse, + errorResponse, + unknownToolResponse, + + // Prompt formatting + formatBlock, + formatList, + formatSection, + + // Token efficiency + truncate, + compactSummary, + + // Agent prompts + AGENT_TEMPLATE, + createAgentPrompt, + + // Platform configs + getOpenCodeConfig, + getCodexConfig, + getInstructionFiles, + INSTRUCTION_FILES, + + // Path normalization + normalizePathForRequire +}; diff --git a/plugins/perf/lib/drift-detect/collectors.js b/plugins/perf/lib/drift-detect/collectors.js new file mode 100644 index 00000000..c76b72b0 --- /dev/null +++ b/plugins/perf/lib/drift-detect/collectors.js @@ -0,0 +1,861 @@ +/** + * Reality Check Data Collectors + * Pure JavaScript data collection - no LLM needed + * + * Replaces three LLM agents (issue-scanner, doc-analyzer, code-explorer) + * with deterministic JavaScript functions. + * + * @module lib/drift-detect/collectors + */ + +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +/** + * Default options for data collection + */ +const DEFAULT_PR_LIMIT = 50; + +const DEFAULT_OPTIONS = { + sources: ['github', 'docs', 'code'], + depth: 'thorough', // quick | thorough + issueLimit: 100, + prLimit: DEFAULT_PR_LIMIT, + timeout: 10000, // 10s + cwd: process.cwd() +}; + +/** + * Validate file path to prevent path traversal + * @param {string} filePath - Path to validate + * @param {string} basePath - Base directory + * @returns {boolean} True if path is safe + */ +function isPathSafe(filePath, basePath) { + const resolved = path.resolve(basePath, filePath); + return resolved.startsWith(path.resolve(basePath)); +} + +/** + * Safe file read with path validation + * @param {string} filePath - Path to read + * @param {string} basePath - Base directory for validation + * @returns {string|null} File contents or null + */ +function safeReadFile(filePath, basePath) { + const fullPath = path.resolve(basePath, filePath); + if (!isPathSafe(filePath, basePath)) { + return null; + } + try { + return fs.readFileSync(fullPath, 'utf8'); + } catch { + return null; + } +} + +/** + * Execute gh CLI command safely + * @param {string[]} args - Command arguments + * @param {Object} options - Execution options + * @returns {Object|null} Parsed JSON result or null + */ +function execGh(args, options = {}) { + try { + const result = execFileSync('gh', args, { + encoding: 'utf8', + stdio: 'pipe', + timeout: options.timeout || DEFAULT_OPTIONS.timeout, + cwd: options.cwd || DEFAULT_OPTIONS.cwd + }); + return JSON.parse(result); + } catch { + return null; + } +} + +/** + * Check if gh CLI is available and authenticated + * @returns {boolean} True if gh is ready + */ +function isGhAvailable() { + try { + execFileSync('gh', ['auth', 'status'], { + encoding: 'utf8', + stdio: 'pipe', + timeout: 5000 + }); + return true; + } catch { + return false; + } +} + +/** + * Summarize an issue for analysis (keep essentials, drop verbose body) + * @param {Object} item - Issue object + * @returns {Object} Summarized item + */ +function summarizeIssue(item) { + return { + number: item.number, + title: item.title, + labels: (item.labels || []).map(l => l.name || l), + milestone: item.milestone?.title || item.milestone || null, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + // First 200 chars of body for context + snippet: item.body ? item.body.slice(0, 200).replace(/\n/g, ' ').trim() + (item.body.length > 200 ? '...' : '') : '' + }; +} + +/** + * Summarize a PR for analysis (include files changed) + * @param {Object} item - PR object + * @returns {Object} Summarized item + */ +function summarizePR(item) { + return { + number: item.number, + title: item.title, + labels: (item.labels || []).map(l => l.name || l), + isDraft: item.isDraft, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + files: item.files || [], + // First 150 chars of body + snippet: item.body ? item.body.slice(0, 150).replace(/\n/g, ' ').trim() + (item.body.length > 150 ? '...' : '') : '' + }; +} + +/** + * Scan GitHub state: issues, PRs, milestones + * Replaces issue-scanner.md agent + * + * @param {Object} options - Collection options + * @returns {Object} GitHub state data + */ +function scanGitHubState(options = {}) { + const opts = { ...DEFAULT_OPTIONS, ...options }; + + const result = { + available: false, + summary: { issueCount: 0, prCount: 0, milestoneCount: 0 }, + issues: [], + prs: [], + milestones: [], + categorized: { bugs: [], features: [], security: [], enhancements: [], other: [] }, + stale: [], + themes: [] + }; + + if (!isGhAvailable()) { + result.error = 'gh CLI not available or not authenticated'; + return result; + } + + result.available = true; + + // Fetch open issues + const issues = execGh([ + 'issue', 'list', + '--state', 'open', + '--json', 'number,title,labels,milestone,createdAt,updatedAt,body', + '--limit', String(opts.issueLimit) + ], opts); + + if (issues) { + // Summarize issues - keep number, title, labels, snippet + result.issues = issues.map(summarizeIssue); + result.summary.issueCount = issues.length; + categorizeIssues(result, issues); + findStaleItems(result, issues, 90); + extractThemes(result, issues); + } + + // Fetch open PRs with files changed + const prs = execGh([ + 'pr', 'list', + '--state', 'open', + '--json', 'number,title,labels,isDraft,createdAt,updatedAt,body,files', + '--limit', String(opts.prLimit) + ], opts); + + if (prs) { + // Summarize PRs - keep number, title, files changed + result.prs = prs.map(summarizePR); + result.summary.prCount = prs.length; + } + + // Fetch milestones + const milestones = execGh([ + 'api', 'repos/{owner}/{repo}/milestones', + '--jq', '.[].{title,state,due_on,open_issues,closed_issues}' + ], opts); + + if (milestones) { + result.milestones = Array.isArray(milestones) ? milestones : [milestones]; + result.summary.milestoneCount = result.milestones.length; + findOverdueMilestones(result); + } + + return result; +} + +/** + * Categorize issues by labels + * + * Uses regexes that treat non-letter characters (start/end of string, space, hyphen, colon, etc.) + * as boundaries to avoid common false positives (e.g., "debug" won't match "bug", but "bug-fix" will). + * Stores issue number + title (enough to understand without lookup). + */ +function categorizeIssues(result, issues) { + const labelMap = { + bug: 'bugs', + 'type: bug': 'bugs', + feature: 'features', + 'type: feature': 'features', + enhancement: 'enhancements', + security: 'security', + 'type: security': 'security' + }; + + // Create regex patterns with word boundaries for more precise matching + const labelPatterns = Object.entries(labelMap).map(([pattern, category]) => ({ + // Match pattern at word boundary (start/end of string, space, hyphen, colon, etc.) + regex: new RegExp(`(^|[^a-z])${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z]|$)`, 'i'), + category + })); + + for (const issue of issues) { + const labels = (issue.labels || []).map(l => (l.name || l).toLowerCase()); + let categorized = false; + // Store number + title for context + const ref = { number: issue.number, title: issue.title }; + + for (const { regex, category } of labelPatterns) { + if (labels.some(l => regex.test(l))) { + result.categorized[category].push(ref); + categorized = true; + break; + } + } + + if (!categorized) { + result.categorized.other.push(ref); + } + } +} + +/** + * Find stale items (not updated in N days) + */ +function findStaleItems(result, items, staleDays) { + const staleDate = new Date(); + staleDate.setDate(staleDate.getDate() - staleDays); + + for (const item of items) { + const updated = new Date(item.updatedAt); + if (updated < staleDate) { + result.stale.push({ + number: item.number, + title: item.title, + lastUpdated: item.updatedAt, + daysStale: Math.floor((Date.now() - updated) / (1000 * 60 * 60 * 24)) + }); + } + } +} + +/** + * Extract common themes from issue titles + */ +function extractThemes(result, issues) { + const words = {}; + const stopWords = new Set(['the', 'a', 'an', 'is', 'are', 'to', 'for', 'in', 'on', 'at', 'with', 'and', 'or', 'of']); + + for (const issue of issues) { + const titleWords = (issue.title || '').toLowerCase().split(/\s+/); + for (const word of titleWords) { + if (word.length > 3 && !stopWords.has(word)) { + words[word] = (words[word] || 0) + 1; + } + } + } + + result.themes = Object.entries(words) + .filter(([, count]) => count > 1) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([word, count]) => ({ word, count })); +} + +/** + * Find overdue milestones + */ +function findOverdueMilestones(result) { + const now = new Date(); + result.overdueMilestones = result.milestones.filter(m => { + if (!m.due_on || m.state === 'closed') return false; + return new Date(m.due_on) < now; + }); +} + +/** + * Analyze documentation files + * Replaces doc-analyzer.md agent + * + * @param {Object} options - Collection options + * @returns {Object} Documentation analysis (condensed) + */ +function analyzeDocumentation(options = {}) { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const basePath = opts.cwd; + + const result = { + summary: { fileCount: 0, totalWords: 0 }, + files: {}, + features: [], + plans: [], + checkboxes: { total: 0, checked: 0, unchecked: 0 }, + gaps: [] + }; + + // Standard documentation files to analyze + const docFiles = [ + 'README.md', + 'PLAN.md', + 'CLAUDE.md', + 'AGENTS.md', + 'CONTRIBUTING.md', + 'CHANGELOG.md', + 'docs/README.md', + 'docs/PLAN.md' + ]; + + for (const file of docFiles) { + const content = safeReadFile(file, basePath); + if (content) { + const analysis = analyzeMarkdownFile(content, file); + result.files[file] = analysis; + result.summary.totalWords += analysis.wordCount; + extractCheckboxes(result, content); + extractFeatures(result, content); + extractPlans(result, content); + } + } + + // Find additional markdown files if depth is thorough (limit to 5) + if (opts.depth === 'thorough') { + const docsDir = path.join(basePath, 'docs'); + if (fs.existsSync(docsDir)) { + try { + const additionalFiles = fs.readdirSync(docsDir) + .filter(f => f.endsWith('.md') && !docFiles.includes(`docs/${f}`)); + + for (const file of additionalFiles.slice(0, 5)) { + const filePath = `docs/${file}`; + const content = safeReadFile(filePath, basePath); + if (content) { + const analysis = analyzeMarkdownFile(content, filePath); + result.files[filePath] = analysis; + result.summary.totalWords += analysis.wordCount; + } + } + } catch { + // Ignore directory read errors + } + } + } + + result.summary.fileCount = Object.keys(result.files).length; + + // Identify documentation gaps + identifyDocGaps(result); + + return result; +} + +/** + * Analyze a single markdown file (condensed output) + */ +function analyzeMarkdownFile(content, filePath) { + // Extract sections (## headers) - limit to first 10 + const sectionMatches = content.match(/^##\s+(.+)$/gm) || []; + const sections = sectionMatches.slice(0, 10).map(s => s.replace(/^##\s+/, '')); + + // Check for common sections + const sectionLower = sections.map(s => s.toLowerCase()).join(' '); + + return { + path: filePath, + sectionCount: sectionMatches.length, + sections: sections, // Top 10 only + hasInstallation: /install|setup|getting.started/i.test(sectionLower), + hasUsage: /usage|how.to|example/i.test(sectionLower), + hasApi: /api|reference|methods/i.test(sectionLower), + hasTesting: /test|spec|coverage/i.test(sectionLower), + codeBlocks: Math.floor((content.match(/```/g) || []).length / 2), + wordCount: content.split(/\s+/).length + }; +} + +/** + * Extract checkboxes from content + */ +function extractCheckboxes(result, content) { + const checked = (content.match(/^[-*]\s+\[x\]/gim) || []).length; + const unchecked = (content.match(/^[-*]\s+\[\s\]/gim) || []).length; + + result.checkboxes.checked += checked; + result.checkboxes.unchecked += unchecked; + result.checkboxes.total += checked + unchecked; +} + +/** + * Extract documented features (limited to top 20) + */ +function extractFeatures(result, content) { + // Look for feature lists + const featurePattern = /^[-*]\s+\*{0,2}(.+?)\*{0,2}(?:\s*[-–]\s*(.+))?$/gm; + let match; + + while ((match = featurePattern.exec(content)) !== null && result.features.length < 20) { + const feature = match[1].trim(); + if (feature.length > 5 && feature.length < 80) { + result.features.push(feature); + } + } + + // Deduplicate and limit + result.features = [...new Set(result.features)].slice(0, 20); +} + +/** + * Extract planned items from content (limited to top 15) + */ +function extractPlans(result, content) { + // Look for TODO, FIXME, future plans sections + const planPatterns = [ + /(?:TODO|FIXME|PLAN):\s*(.+)/gi, + /^##\s+(?:Roadmap|Future|Planned|Coming Soon)/gim + ]; + + for (const pattern of planPatterns) { + let match; + while ((match = pattern.exec(content)) !== null && result.plans.length < 15) { + const plan = (match[1] || match[0]).slice(0, 100); // Truncate long plans + result.plans.push(plan); + } + } +} + +/** + * Identify documentation gaps + */ +function identifyDocGaps(result) { + const readme = result.files['README.md']; + + if (!readme) { + result.gaps.push({ type: 'missing', file: 'README.md', severity: 'high' }); + } else { + if (!readme.hasInstallation) { + result.gaps.push({ type: 'missing-section', file: 'README.md', section: 'Installation', severity: 'medium' }); + } + if (!readme.hasUsage) { + result.gaps.push({ type: 'missing-section', file: 'README.md', section: 'Usage', severity: 'medium' }); + } + } + + if (!result.files['CHANGELOG.md']) { + result.gaps.push({ type: 'missing', file: 'CHANGELOG.md', severity: 'low' }); + } +} + +/** + * Scan codebase structure and features + * Replaces code-explorer.md agent + * + * @param {Object} options - Collection options + * @returns {Object} Codebase analysis (condensed) + */ +function scanCodebase(options = {}) { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const basePath = opts.cwd; + + const result = { + summary: { totalDirs: 0, totalFiles: 0 }, + topLevelDirs: [], + frameworks: [], + testFramework: null, + hasTypeScript: false, + implementedFeatures: [], + symbols: {}, // Function/class/export names per file + health: { + hasTests: false, + hasLinting: false, + hasCi: false, + hasReadme: false + }, + fileStats: {} + }; + + // Internal structure for scanning (not exposed in full) + const internalStructure = {}; + + // Detect package.json dependencies + const pkgContent = safeReadFile('package.json', basePath); + if (pkgContent) { + try { + const pkg = JSON.parse(pkgContent); + detectFrameworks(result, pkg); + detectTestFramework(result, pkg); + } catch { + // Invalid JSON + } + } + + // Check for TypeScript + result.hasTypeScript = fs.existsSync(path.join(basePath, 'tsconfig.json')); + + // Scan directory structure (internal) + scanDirectory({ structure: internalStructure, fileStats: result.fileStats }, basePath, '', opts.depth === 'thorough' ? 3 : 2); + + // Extract summary from internal structure + result.summary.totalDirs = Object.keys(internalStructure).length; + result.summary.totalFiles = Object.values(internalStructure).reduce((sum, d) => sum + (d.fileCount || 0), 0); + + // Get top-level directories only + const rootEntry = internalStructure['.']; + if (rootEntry) { + result.topLevelDirs = rootEntry.dirs || []; + } + + // Detect health indicators + detectHealth(result, basePath); + + // Find implemented features from code + if (opts.depth === 'thorough') { + findImplementedFeatures({ ...result, structure: internalStructure }, basePath); + // Extract symbols from source files + result.symbols = scanFileSymbols(basePath, result.topLevelDirs); + } + + // Limit fileStats to top 10 extensions + const sortedStats = Object.entries(result.fileStats) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10); + result.fileStats = Object.fromEntries(sortedStats); + + return result; +} + +/** + * Detect frameworks from package.json + */ +function detectFrameworks(result, pkgJson) { + const deps = { ...pkgJson.dependencies, ...pkgJson.devDependencies }; + const frameworkMap = { + react: 'React', + 'react-dom': 'React', + next: 'Next.js', + vue: 'Vue.js', + nuxt: 'Nuxt', + angular: 'Angular', + express: 'Express', + fastify: 'Fastify', + koa: 'Koa', + nestjs: 'NestJS' + }; + + for (const [pkgName, framework] of Object.entries(frameworkMap)) { + if (deps[pkgName]) { + result.frameworks.push(framework); + } + } + + result.frameworks = [...new Set(result.frameworks)]; +} + +/** + * Detect test framework + */ +function detectTestFramework(result, pkgJson) { + const deps = { ...pkgJson.dependencies, ...pkgJson.devDependencies }; + const testFrameworks = ['jest', 'mocha', 'vitest', 'ava', 'tap', 'jasmine']; + + for (const framework of testFrameworks) { + if (deps[framework]) { + result.testFramework = framework; + result.health.hasTests = true; + break; + } + } +} + +/** + * Extract symbols (functions, classes, exports) from a JS/TS file + * Uses regex patterns - not a full parser, but good enough for analysis + * @param {string} content - File content + * @returns {Object} Extracted symbols + */ +function extractSymbols(content) { + const symbols = { + functions: [], + classes: [], + exports: [] + }; + + // Function declarations: function foo() or async function foo() + const funcPattern = /(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/g; + let match; + while ((match = funcPattern.exec(content)) !== null) { + symbols.functions.push(match[1]); + } + + // Arrow functions assigned to const/let: const foo = () => or const foo = async () => + const arrowPattern = /(?:const|let)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>/g; + while ((match = arrowPattern.exec(content)) !== null) { + symbols.functions.push(match[1]); + } + + // Class declarations: class Foo + const classPattern = /class\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g; + while ((match = classPattern.exec(content)) !== null) { + symbols.classes.push(match[1]); + } + + // Named exports: export { foo, bar } or export function foo + const namedExportPattern = /export\s+(?:(?:async\s+)?function|class|const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g; + while ((match = namedExportPattern.exec(content)) !== null) { + symbols.exports.push(match[1]); + } + + // module.exports = { foo, bar } - extract keys + const moduleExportsPattern = /module\.exports\s*=\s*\{([^}]+)\}/; + const moduleMatch = content.match(moduleExportsPattern); + if (moduleMatch) { + const keys = moduleMatch[1].split(',').map(k => k.trim().split(':')[0].trim()); + symbols.exports.push(...keys.filter(k => k && /^[a-zA-Z_$]/.test(k))); + } + + // Deduplicate + symbols.functions = [...new Set(symbols.functions)]; + symbols.classes = [...new Set(symbols.classes)]; + symbols.exports = [...new Set(symbols.exports)]; + + return symbols; +} + +/** + * Scan key source files for symbols (recursive) + * @param {string} basePath - Project root + * @param {string[]} topLevelDirs - Top-level directories + * @returns {Object} File -> symbols mapping + */ +function scanFileSymbols(basePath, topLevelDirs) { + const sourceSymbols = {}; + const sourceDirs = ['lib', 'src', 'app', 'pages', 'components', 'utils', 'services', 'api']; + const dirsToScan = topLevelDirs.filter(d => sourceDirs.includes(d)); + + let filesScanned = 0; + const maxFiles = 40; // Limit to avoid huge output + + function scanDir(dirPath, relativePath, depth = 0) { + if (filesScanned >= maxFiles || depth > 2) return; + if (!fs.existsSync(dirPath)) return; + + try { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + if (filesScanned >= maxFiles) break; + + const fullPath = path.join(dirPath, entry.name); + const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name; + + if (entry.isDirectory()) { + // Skip common non-source dirs + if (['node_modules', '__tests__', 'test', 'tests', 'dist', 'build'].includes(entry.name)) continue; + scanDir(fullPath, relPath, depth + 1); + } else if (entry.isFile()) { + if (!/\.(js|ts|jsx|tsx)$/.test(entry.name)) continue; + if (entry.name.includes('.test.') || entry.name.includes('.spec.')) continue; + + try { + const stat = fs.statSync(fullPath); + if (stat.size > 50000) continue; // Skip large files + + const content = fs.readFileSync(fullPath, 'utf8'); + const symbols = extractSymbols(content); + + // Only include if has meaningful symbols + if (symbols.functions.length || symbols.classes.length || symbols.exports.length) { + sourceSymbols[relPath] = symbols; + filesScanned++; + } + } catch { + // Skip unreadable files + } + } + } + } catch { + // Skip unreadable dirs + } + } + + for (const dir of dirsToScan) { + if (filesScanned >= maxFiles) break; + scanDir(path.join(basePath, dir), dir); + } + + return sourceSymbols; +} + +/** + * Scan directory structure recursively + */ +function scanDirectory(result, basePath, relativePath, maxDepth, depth = 0) { + if (depth >= maxDepth) return; + + const fullPath = path.join(basePath, relativePath); + if (!fs.existsSync(fullPath)) return; + + try { + const entries = fs.readdirSync(fullPath, { withFileTypes: true }); + const dirs = []; + const files = []; + + for (const entry of entries) { + // Skip common excluded directories + if (entry.isDirectory()) { + if (['node_modules', '.git', 'dist', 'build', 'coverage', '.claude'].includes(entry.name)) { + continue; + } + dirs.push(entry.name); + } else { + files.push(entry.name); + } + } + + // Store structure + const key = relativePath || '.'; + result.structure[key] = { dirs, fileCount: files.length }; + + // Count files by extension + for (const file of files) { + const ext = path.extname(file).toLowerCase() || 'no-ext'; + result.fileStats[ext] = (result.fileStats[ext] || 0) + 1; + } + + // Recurse into subdirectories + for (const dir of dirs) { + scanDirectory(result, basePath, path.join(relativePath, dir), maxDepth, depth + 1); + } + } catch { + // Permission or read errors + } +} + +/** + * Detect project health indicators + */ +function detectHealth(result, basePath) { + // Check for README + result.health.hasReadme = fs.existsSync(path.join(basePath, 'README.md')); + + // Check for linting config + const lintConfigs = ['.eslintrc', '.eslintrc.js', '.eslintrc.json', 'eslint.config.js', 'biome.json']; + result.health.hasLinting = lintConfigs.some(f => fs.existsSync(path.join(basePath, f))); + + // Check for CI config + const ciConfigs = [ + '.github/workflows', + '.gitlab-ci.yml', + '.circleci', + 'Jenkinsfile', + '.travis.yml' + ]; + result.health.hasCi = ciConfigs.some(f => fs.existsSync(path.join(basePath, f))); + + // Check for tests directory + const testDirs = ['tests', '__tests__', 'test', 'spec']; + result.health.hasTests = result.health.hasTests || testDirs.some(d => fs.existsSync(path.join(basePath, d))); +} + +/** + * Find implemented features from code patterns + */ +function findImplementedFeatures(result, basePath) { + // Common feature indicators + const featurePatterns = { + authentication: ['auth', 'login', 'session', 'jwt', 'oauth'], + api: ['routes', 'controllers', 'handlers', 'endpoints'], + database: ['models', 'schemas', 'migrations', 'seeds'], + ui: ['components', 'views', 'pages', 'layouts'], + testing: ['__tests__', 'test', 'spec', '.test.', '.spec.'], + docs: ['docs', 'documentation', 'wiki'] + }; + + for (const [feature, patterns] of Object.entries(featurePatterns)) { + const found = patterns.some(pattern => { + // Check directory structure + for (const dir of Object.keys(result.structure)) { + if (dir.toLowerCase().includes(pattern)) { + return true; + } + } + return false; + }); + + if (found) { + result.implementedFeatures.push(feature); + } + } +} + +/** + * Collect all data from all sources + * Main entry point for data collection + * + * @param {Object} options - Collection options + * @returns {Object} All collected data + */ +function collectAllData(options = {}) { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const sources = Array.isArray(opts.sources) ? opts.sources : DEFAULT_OPTIONS.sources; + + const data = { + timestamp: new Date().toISOString(), + options: opts, + github: null, + docs: null, + code: null + }; + + // Collect from each enabled source + if (sources.includes('github')) { + data.github = scanGitHubState(opts); + } + + if (sources.includes('docs')) { + data.docs = analyzeDocumentation(opts); + } + + if (sources.includes('code')) { + data.code = scanCodebase(opts); + } + + return data; +} + +module.exports = { + DEFAULT_OPTIONS, + scanGitHubState, + analyzeDocumentation, + scanCodebase, + collectAllData, + isGhAvailable, + isPathSafe +}; diff --git a/plugins/perf/lib/enhance/agent-analyzer.js b/plugins/perf/lib/enhance/agent-analyzer.js new file mode 100644 index 00000000..22a98a84 --- /dev/null +++ b/plugins/perf/lib/enhance/agent-analyzer.js @@ -0,0 +1,421 @@ +/** + * Agent Analyzer + * Main orchestrator for agent prompt optimization analysis + * + * @author Avi Fenesh + * @license MIT + */ + +const fs = require('fs'); +const path = require('path'); +const { agentPatterns } = require('./agent-patterns'); + +/** + * Parse YAML frontmatter from markdown content + * @param {string} content - Markdown file content + * @returns {Object} { frontmatter, body } + */ +function parseMarkdownFrontmatter(content) { + if (!content || typeof content !== 'string') { + return { frontmatter: null, body: content }; + } + + const trimmed = content.trim(); + + // Check if starts with --- + if (!trimmed.startsWith('---')) { + return { frontmatter: null, body: content }; + } + + // Find closing --- + const lines = trimmed.split('\n'); + let endIndex = -1; + + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === '---') { + endIndex = i; + break; + } + } + + if (endIndex === -1) { + return { frontmatter: null, body: content }; + } + + // Parse frontmatter as simple key: value pairs + const frontmatter = {}; + const fmLines = lines.slice(1, endIndex); + + for (const line of fmLines) { + const colonIndex = line.indexOf(':'); + if (colonIndex > 0) { + const key = line.substring(0, colonIndex).trim(); + const value = line.substring(colonIndex + 1).trim(); + frontmatter[key] = value; + } + } + + // Body is everything after closing --- + const body = lines.slice(endIndex + 1).join('\n'); + + return { frontmatter, body }; +} + +/** + * Analyze a single agent file + * @param {string} agentPath - Path to agent markdown file + * @param {Object} options - Analysis options + * @param {boolean} options.verbose - Include LOW certainty issues + * @returns {Object} Analysis results + */ +function analyzeAgent(agentPath, options = {}) { + const results = { + agentName: path.basename(agentPath, '.md'), + agentPath, + frontmatter: null, + structureIssues: [], + toolIssues: [], + xmlIssues: [], + cotIssues: [], + exampleIssues: [], + antiPatternIssues: [], + crossPlatformIssues: [] + }; + + // Read file + if (!fs.existsSync(agentPath)) { + results.structureIssues.push({ + issue: 'File not found', + file: agentPath, + certainty: 'HIGH', + patternId: 'file_not_found' + }); + return results; + } + + let content; + try { + content = fs.readFileSync(agentPath, 'utf8'); + } catch (err) { + results.structureIssues.push({ + issue: `Failed to read file: ${err.message}`, + file: agentPath, + certainty: 'HIGH', + patternId: 'read_error' + }); + return results; + } + + // Parse frontmatter + const { frontmatter } = parseMarkdownFrontmatter(content); + results.frontmatter = frontmatter; + + // Check for missing frontmatter + const missingFmPattern = agentPatterns.missing_frontmatter; + const missingFmResult = missingFmPattern.check(content); + if (missingFmResult) { + results.structureIssues.push({ + ...missingFmResult, + file: agentPath, + certainty: missingFmPattern.certainty, + patternId: missingFmPattern.id + }); + } + + // If frontmatter exists, check its fields + if (frontmatter) { + // Check for missing name + const missingNamePattern = agentPatterns.missing_name; + const missingNameResult = missingNamePattern.check(frontmatter); + if (missingNameResult) { + results.structureIssues.push({ + ...missingNameResult, + file: agentPath, + certainty: missingNamePattern.certainty, + patternId: missingNamePattern.id + }); + } + + // Check for missing description + const missingDescPattern = agentPatterns.missing_description; + const missingDescResult = missingDescPattern.check(frontmatter); + if (missingDescResult) { + results.structureIssues.push({ + ...missingDescResult, + file: agentPath, + certainty: missingDescPattern.certainty, + patternId: missingDescPattern.id + }); + } + + // Check for unrestricted tools + const unrestrictedToolsPattern = agentPatterns.unrestricted_tools; + const unrestrictedToolsResult = unrestrictedToolsPattern.check(frontmatter); + if (unrestrictedToolsResult) { + results.toolIssues.push({ + ...unrestrictedToolsResult, + file: agentPath, + certainty: unrestrictedToolsPattern.certainty, + patternId: unrestrictedToolsPattern.id + }); + } + + // Check for unrestricted Bash + const unrestrictedBashPattern = agentPatterns.unrestricted_bash; + const unrestrictedBashResult = unrestrictedBashPattern.check(frontmatter); + if (unrestrictedBashResult) { + results.toolIssues.push({ + ...unrestrictedBashResult, + file: agentPath, + filePath: agentPath, + certainty: unrestrictedBashPattern.certainty, + patternId: unrestrictedBashPattern.id + }); + } + } + + // Check for missing role + const missingRolePattern = agentPatterns.missing_role; + const missingRoleResult = missingRolePattern.check(content); + if (missingRoleResult) { + results.structureIssues.push({ + ...missingRoleResult, + file: agentPath, + filePath: agentPath, + certainty: missingRolePattern.certainty, + patternId: missingRolePattern.id + }); + } + + // Check for missing output format + const missingOutputPattern = agentPatterns.missing_output_format; + const missingOutputResult = missingOutputPattern.check(content); + if (missingOutputResult) { + results.structureIssues.push({ + ...missingOutputResult, + file: agentPath, + certainty: missingOutputPattern.certainty, + patternId: missingOutputPattern.id + }); + } + + // Check for missing constraints + const missingConstraintsPattern = agentPatterns.missing_constraints; + const missingConstraintsResult = missingConstraintsPattern.check(content); + if (missingConstraintsResult) { + results.structureIssues.push({ + ...missingConstraintsResult, + file: agentPath, + certainty: missingConstraintsPattern.certainty, + patternId: missingConstraintsPattern.id + }); + } + + // Check for missing XML structure + const missingXmlPattern = agentPatterns.missing_xml_structure; + const missingXmlResult = missingXmlPattern.check(content); + if (missingXmlResult && (options.verbose || missingXmlPattern.certainty !== 'LOW')) { + results.xmlIssues.push({ + ...missingXmlResult, + file: agentPath, + certainty: missingXmlPattern.certainty, + patternId: missingXmlPattern.id + }); + } + + // Check for unnecessary CoT + const unnecessaryCotPattern = agentPatterns.unnecessary_cot; + const unnecessaryCotResult = unnecessaryCotPattern.check(content); + if (unnecessaryCotResult && (options.verbose || unnecessaryCotPattern.certainty !== 'LOW')) { + results.cotIssues.push({ + ...unnecessaryCotResult, + file: agentPath, + certainty: unnecessaryCotPattern.certainty, + patternId: unnecessaryCotPattern.id + }); + } + + // Check for missing CoT + const missingCotPattern = agentPatterns.missing_cot; + const missingCotResult = missingCotPattern.check(content); + if (missingCotResult && (options.verbose || missingCotPattern.certainty !== 'LOW')) { + results.cotIssues.push({ + ...missingCotResult, + file: agentPath, + certainty: missingCotPattern.certainty, + patternId: missingCotPattern.id + }); + } + + // Check example count + const exampleCountPattern = agentPatterns.example_count_suboptimal; + const exampleCountResult = exampleCountPattern.check(content); + if (exampleCountResult && options.verbose) { + results.exampleIssues.push({ + ...exampleCountResult, + file: agentPath, + certainty: exampleCountPattern.certainty, + patternId: exampleCountPattern.id + }); + } + + // Check for vague instructions + const vaguePattern = agentPatterns.vague_instructions; + const vagueResult = vaguePattern.check(content); + if (vagueResult && (options.verbose || vaguePattern.certainty !== 'LOW')) { + results.antiPatternIssues.push({ + ...vagueResult, + file: agentPath, + certainty: vaguePattern.certainty, + patternId: vaguePattern.id + }); + } + + // Check for prompt bloat + const bloatPattern = agentPatterns.prompt_bloat; + const bloatResult = bloatPattern.check(content); + if (bloatResult && options.verbose) { + results.antiPatternIssues.push({ + ...bloatResult, + file: agentPath, + certainty: bloatPattern.certainty, + patternId: bloatPattern.id + }); + } + + // Cross-platform compatibility checks + const crossPlatformPatterns = [ + 'hardcoded_claude_dir', + 'claude_md_reference', + 'no_xml_for_data' + ]; + + for (const patternName of crossPlatformPatterns) { + const pattern = agentPatterns[patternName]; + if (!pattern) continue; + + const result = pattern.check(content); + if (result && (options.verbose || pattern.certainty !== 'LOW')) { + results.crossPlatformIssues.push({ + ...result, + file: agentPath, + certainty: pattern.certainty, + patternId: pattern.id + }); + } + } + + return results; +} + +/** + * Analyze all agents in a directory + * @param {string} agentsDir - Path to agents directory + * @param {Object} options - Analysis options + * @returns {Array} Array of analysis results + */ +function analyzeAllAgents(agentsDir, options = {}) { + const results = []; + + if (!fs.existsSync(agentsDir)) { + return results; + } + + const agentFiles = fs.readdirSync(agentsDir) + .filter(f => f.endsWith('.md') && f !== 'README.md'); + + for (const agentFile of agentFiles) { + const agentPath = path.join(agentsDir, agentFile); + const result = analyzeAgent(agentPath, options); + results.push(result); + } + + return results; +} + +/** + * Main analyze function + * @param {Object} options - Analysis options + * @param {string} options.agent - Specific agent name (optional) + * @param {string} options.agentsDir - Path to agents directory + * @param {boolean} options.verbose - Include LOW certainty issues + * @returns {Object|Array} Analysis results + */ +function analyze(options = {}) { + const { + agent, + agentsDir = 'plugins/enhance/agents', + verbose = false + } = options; + + if (agent) { + // Analyze single agent + const agentPath = agent.endsWith('.md') + ? path.join(agentsDir, agent) + : path.join(agentsDir, `${agent}.md`); + return analyzeAgent(agentPath, { verbose }); + } else { + // Analyze all agents + return analyzeAllAgents(agentsDir, { verbose }); + } +} + +/** + * Apply fixes to analysis results + * @param {Object|Array} results - Analysis results + * @param {Object} options - Fix options + * @returns {Object} Fix results + */ +function applyFixes(results, options = {}) { + const fixer = require('./fixer'); + + // Collect all issues + let allIssues = []; + + if (Array.isArray(results)) { + for (const r of results) { + allIssues.push(...(r.structureIssues || [])); + allIssues.push(...(r.toolIssues || [])); + allIssues.push(...(r.xmlIssues || [])); + allIssues.push(...(r.cotIssues || [])); + allIssues.push(...(r.exampleIssues || [])); + allIssues.push(...(r.antiPatternIssues || [])); + allIssues.push(...(r.crossPlatformIssues || [])); + } + } else { + allIssues.push(...(results.structureIssues || [])); + allIssues.push(...(results.toolIssues || [])); + allIssues.push(...(results.xmlIssues || [])); + allIssues.push(...(results.cotIssues || [])); + allIssues.push(...(results.exampleIssues || [])); + allIssues.push(...(results.antiPatternIssues || [])); + allIssues.push(...(results.crossPlatformIssues || [])); + } + + return fixer.applyFixes(allIssues, options); +} + +/** + * Generate report from analysis results + * @param {Object|Array} results - Analysis results + * @param {Object} options - Report options + * @returns {string} Markdown report + */ +function generateReport(results, options = {}) { + const reporter = require('./reporter'); + + if (Array.isArray(results)) { + return reporter.generateAgentSummaryReport(results, options); + } else { + return reporter.generateAgentReport(results, options); + } +} + +module.exports = { + parseMarkdownFrontmatter, + analyzeAgent, + analyzeAllAgents, + analyze, + applyFixes, + generateReport +}; diff --git a/plugins/perf/lib/enhance/agent-patterns.js b/plugins/perf/lib/enhance/agent-patterns.js new file mode 100644 index 00000000..2bc0d67e --- /dev/null +++ b/plugins/perf/lib/enhance/agent-patterns.js @@ -0,0 +1,569 @@ +/** + * Agent Prompt Patterns + * Detection patterns for agent prompt engineering best practices + * + * @author Avi Fenesh + * @license MIT + */ + +/** + * Agent prompt patterns with certainty levels + * Following the plugin-patterns model + */ +const agentPatterns = { + /** + * Missing YAML frontmatter + * HIGH certainty - always fixable + */ + missing_frontmatter: { + id: 'missing_frontmatter', + category: 'structure', + certainty: 'HIGH', + autoFix: true, + description: 'Agent prompt missing YAML frontmatter (---...---)', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + // Check if frontmatter exists + const hasFrontmatter = content.trim().startsWith('---'); + + if (!hasFrontmatter) { + return { + issue: 'Missing YAML frontmatter', + fix: 'Add frontmatter with name, description, tools, model' + }; + } + return null; + } + }, + + /** + * Missing name field in frontmatter + * HIGH certainty - requires manual fix (name is context-dependent) + */ + missing_name: { + id: 'missing_name', + category: 'structure', + certainty: 'HIGH', + autoFix: false, + description: 'Frontmatter missing "name" field', + check: (frontmatter) => { + if (!frontmatter || typeof frontmatter !== 'object') return null; + + if (!frontmatter.name || (typeof frontmatter.name === 'string' && frontmatter.name.trim() === '')) { + return { + issue: 'Frontmatter missing "name" field', + fix: 'Add "name" field to frontmatter' + }; + } + return null; + } + }, + + /** + * Missing description field in frontmatter + * HIGH certainty - requires manual fix (description is context-dependent) + */ + missing_description: { + id: 'missing_description', + category: 'structure', + certainty: 'HIGH', + autoFix: false, + description: 'Frontmatter missing "description" field', + check: (frontmatter) => { + if (!frontmatter || typeof frontmatter !== 'object') return null; + + if (!frontmatter.description || (typeof frontmatter.description === 'string' && frontmatter.description.trim() === '')) { + return { + issue: 'Frontmatter missing "description" field', + fix: 'Add "description" field to frontmatter' + }; + } + return null; + } + }, + + /** + * Missing role section + * HIGH certainty - should have clear role definition + */ + missing_role: { + id: 'missing_role', + category: 'structure', + certainty: 'HIGH', + autoFix: true, + description: 'No role section ("You are..." or "## Role")', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + // Look for role indicators + const hasYouAre = /you are/i.test(content); + const hasRoleSection = /##\s+(?:your\s+)?role/i.test(content); + + if (!hasYouAre && !hasRoleSection) { + return { + issue: 'Missing role definition', + fix: 'Add role section explaining agent purpose' + }; + } + return null; + } + }, + + /** + * Missing output format specification + * HIGH certainty - agents should specify output format + */ + missing_output_format: { + id: 'missing_output_format', + category: 'structure', + certainty: 'HIGH', + autoFix: false, + description: 'No output format specification', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + // Look for output format indicators + const hasOutputFormat = /##\s+output\s+format/i.test(content); + const hasFormatSection = /##\s+format/i.test(content); + const hasResponseFormat = /##\s+response/i.test(content); + + if (!hasOutputFormat && !hasFormatSection && !hasResponseFormat) { + return { + issue: 'Missing output format specification', + fix: 'Add section specifying expected output format' + }; + } + return null; + } + }, + + /** + * Missing constraints section + * HIGH certainty - agents should have clear constraints + */ + missing_constraints: { + id: 'missing_constraints', + category: 'structure', + certainty: 'HIGH', + autoFix: false, + description: 'No constraints section', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + // Look for constraints indicators + const hasConstraints = /##\s+constraints/i.test(content); + const hasDontSection = /##\s+(?:what\s+)?(?:you\s+)?(?:must\s+)?not\s+do/i.test(content); + const hasRulesSection = /##\s+rules/i.test(content); + + if (!hasConstraints && !hasDontSection && !hasRulesSection) { + return { + issue: 'Missing constraints section', + fix: 'Add section defining agent limitations and boundaries' + }; + } + return null; + } + }, + + /** + * Unrestricted tools in frontmatter + * HIGH certainty - no tools field means all tools allowed + */ + unrestricted_tools: { + id: 'unrestricted_tools', + category: 'tool', + certainty: 'HIGH', + autoFix: false, + description: 'No "tools" field in frontmatter (all tools allowed)', + check: (frontmatter) => { + if (!frontmatter || typeof frontmatter !== 'object') return null; + + if (!frontmatter.tools) { + return { + issue: 'No tools restriction - agent has access to all tools', + fix: 'Add "tools" field to frontmatter with specific tools needed' + }; + } + return null; + } + }, + + /** + * Unrestricted Bash tool + * HIGH certainty - Bash without restrictions is dangerous + */ + unrestricted_bash: { + id: 'unrestricted_bash', + category: 'tool', + certainty: 'HIGH', + autoFix: true, + description: 'Has "Bash" without restrictions (should be "Bash(git:*)" etc)', + check: (frontmatter) => { + if (!frontmatter || typeof frontmatter !== 'object') return null; + + if (frontmatter.tools) { + const toolsArray = Array.isArray(frontmatter.tools) + ? frontmatter.tools + : frontmatter.tools.split(',').map(t => t.trim()); + + const hasUnrestrictedBash = toolsArray.some(t => + t === 'Bash' || t === 'bash' + ); + + if (hasUnrestrictedBash) { + return { + issue: 'Unrestricted Bash access', + fix: 'Replace "Bash" with "Bash(git:*)" or specific scope' + }; + } + } + return null; + } + }, + + /** + * Missing XML structure for complex data + * MEDIUM certainty - beneficial for structured prompts + */ + missing_xml_structure: { + id: 'missing_xml_structure', + category: 'xml', + certainty: 'MEDIUM', + autoFix: false, + description: 'Could benefit from XML tags for structure', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + // Check if content is complex enough to benefit from XML + const sectionCount = (content.match(/##\s+/g) || []).length; + const hasLists = /^\s*[-*]\s+/m.test(content); + const hasCodeBlocks = /```/g.test(content); + + // If complex but no XML tags + if (sectionCount >= 5 || (hasLists && hasCodeBlocks)) { + const hasXML = /<\w+>/.test(content); + + if (!hasXML) { + return { + issue: 'Complex prompt without XML structure', + fix: 'Consider using XML tags for key sections (e.g., , )' + }; + } + } + return null; + } + }, + + /** + * Unnecessary step-by-step reasoning + * MEDIUM certainty - step-by-step on simple tasks + */ + unnecessary_cot: { + id: 'unnecessary_cot', + category: 'cot', + certainty: 'MEDIUM', + autoFix: false, + description: 'Step-by-step reasoning on simple tasks', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + // Look for step-by-step language + const hasStepByStep = /step[- ]by[- ]step/i.test(content); + const hasThinkingTags = //i.test(content); + + // Check if task is simple (short prompt, few sections) + const wordCount = content.split(/\s+/).length; + const sectionCount = (content.match(/##\s+/g) || []).length; + + if ((hasStepByStep || hasThinkingTags) && wordCount < 500 && sectionCount < 4) { + return { + issue: 'Unnecessary chain-of-thought for simple task', + fix: 'Remove step-by-step instructions for straightforward operations' + }; + } + return null; + } + }, + + /** + * Missing chain-of-thought for complex reasoning + * MEDIUM certainty - complex tasks benefit from CoT + */ + missing_cot: { + id: 'missing_cot', + category: 'cot', + certainty: 'MEDIUM', + autoFix: false, + description: 'Complex reasoning without thinking guidance', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + // Check if task is complex + const wordCount = content.split(/\s+/).length; + const sectionCount = (content.match(/##\s+/g) || []).length; + const hasAnalysis = /analy[sz]e|evaluate|assess|review/i.test(content); + + // Look for CoT indicators + const hasStepByStep = /step[- ]by[- ]step/i.test(content); + const hasThinkingTags = //i.test(content); + const hasReasoningGuidance = /reasoning|think\s+through/i.test(content); + + if (wordCount > 1000 && sectionCount >= 5 && hasAnalysis) { + if (!hasStepByStep && !hasThinkingTags && !hasReasoningGuidance) { + return { + issue: 'Complex task without reasoning guidance', + fix: 'Add chain-of-thought instructions or tags' + }; + } + } + return null; + } + }, + + /** + * Suboptimal example count + * LOW certainty - 2-5 examples is generally optimal + */ + example_count_suboptimal: { + id: 'example_count_suboptimal', + category: 'example', + certainty: 'LOW', + autoFix: false, + description: 'Not 2-5 examples', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + // Count example sections + const exampleCount = (content.match(/##\s+example/gi) || []).length; + const goodExample = (content.match(//gi) || []).length; + const badExample = (content.match(//gi) || []).length; + + const totalExamples = exampleCount + goodExample + badExample; + + if (totalExamples > 0 && (totalExamples < 2 || totalExamples > 5)) { + return { + issue: `Found ${totalExamples} examples (optimal: 2-5)`, + fix: totalExamples < 2 + ? 'Consider adding more examples for clarity' + : 'Consider reducing examples to avoid token bloat' + }; + } + return null; + } + }, + + /** + * Vague instructions + * MEDIUM certainty - fuzzy language reduces effectiveness + */ + vague_instructions: { + id: 'vague_instructions', + category: 'anti-pattern', + certainty: 'MEDIUM', + autoFix: false, + description: 'Fuzzy language like "usually", "sometimes"', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + // Look for vague words + const vagueWords = [ + 'usually', 'sometimes', 'often', 'rarely', 'maybe', + 'might', 'could', 'should probably', 'try to', + 'as much as possible', 'if possible' + ]; + + const found = []; + for (const word of vagueWords) { + const regex = new RegExp(`\\b${word}\\b`, 'gi'); + if (regex.test(content)) { + found.push(word); + } + } + + if (found.length > 3) { + return { + issue: `Found vague language: ${found.slice(0, 3).join(', ')}...`, + fix: 'Replace fuzzy language with clear, definitive instructions' + }; + } + return null; + } + }, + + /** + * Prompt bloat + * LOW certainty - long prompts use more tokens + */ + prompt_bloat: { + id: 'prompt_bloat', + category: 'anti-pattern', + certainty: 'LOW', + autoFix: false, + description: 'Token count > 2000', + maxTokens: 2000, + check: (content) => { + if (!content || typeof content !== 'string') return null; + + // Rough token estimate (1 token ≈ 4 characters) + const estimatedTokens = Math.ceil(content.length / 4); + + if (estimatedTokens > 2000) { + return { + issue: `Prompt ~${estimatedTokens} tokens (max recommended: 2000)`, + fix: 'Simplify prompt, remove redundant sections, or use XML for compression' + }; + } + return null; + } + }, + + // ============================================ + // CROSS-PLATFORM COMPATIBILITY PATTERNS + // ============================================ + + /** + * Hardcoded .claude/ state directory + * HIGH certainty - breaks OpenCode/Codex + */ + hardcoded_claude_dir: { + id: 'hardcoded_claude_dir', + category: 'cross-platform', + certainty: 'HIGH', + autoFix: false, + description: 'Hardcoded .claude/ directory (breaks OpenCode/Codex)', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + // Look for hardcoded .claude/ references + const hasHardcoded = /\.claude\//.test(content); + // Exclude if using AI_STATE_DIR + const usesEnvVar = /AI_STATE_DIR|\$\{.*STATE.*\}/i.test(content); + + if (hasHardcoded && !usesEnvVar) { + return { + issue: 'Hardcoded .claude/ directory path', + fix: 'Use AI_STATE_DIR env var or platform detection for cross-platform support' + }; + } + return null; + } + }, + + /** + * CLAUDE.md reference without AGENTS.md + * MEDIUM certainty - OpenCode/Codex use AGENTS.md + */ + claude_md_reference: { + id: 'claude_md_reference', + category: 'cross-platform', + certainty: 'MEDIUM', + autoFix: false, + description: 'References CLAUDE.md without also checking AGENTS.md', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + const hasClaudeMd = /CLAUDE\.md/i.test(content); + const hasAgentsMd = /AGENTS\.md/i.test(content); + + // Only flag if mentions CLAUDE.md but not AGENTS.md + if (hasClaudeMd && !hasAgentsMd) { + return { + issue: 'References CLAUDE.md without AGENTS.md', + fix: 'Also check for AGENTS.md (used by OpenCode/Codex)' + }; + } + return null; + } + }, + + /** + * Missing XML for cross-model compatibility + * LOW certainty - XML helps both Claude and GPT-4 + */ + no_xml_for_data: { + id: 'no_xml_for_data', + category: 'cross-platform', + certainty: 'LOW', + autoFix: false, + description: 'Data blocks without XML tags (helps both Claude and GPT-4)', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + // Check if has code blocks or lists but no XML + const hasCodeBlocks = /```[\s\S]+?```/.test(content); + const hasLists = /^[-*]\s+.+$/m.test(content); + const hasXML = /<\w+>[\s\S]*?<\/\w+>/.test(content); + const sectionCount = (content.match(/^##\s+/gm) || []).length; + + // Complex content without XML + if ((hasCodeBlocks || hasLists) && sectionCount >= 4 && !hasXML) { + return { + issue: 'Complex content without XML tags', + fix: 'Wrap data blocks in XML tags (e.g., , ) for cross-model compatibility' + }; + } + return null; + } + } +}; + +/** + * Get all patterns + * @returns {Object} All agent patterns + */ +function getAllPatterns() { + return agentPatterns; +} + +/** + * Get patterns by certainty level + * @param {string} certainty - HIGH, MEDIUM, or LOW + * @returns {Object} Filtered patterns + */ +function getPatternsByCertainty(certainty) { + const result = {}; + for (const [name, pattern] of Object.entries(agentPatterns)) { + if (pattern.certainty === certainty) { + result[name] = pattern; + } + } + return result; +} + +/** + * Get patterns by category + * @param {string} category - structure, tool, xml, cot, example, anti-pattern + * @returns {Object} Filtered patterns + */ +function getPatternsByCategory(category) { + const result = {}; + for (const [name, pattern] of Object.entries(agentPatterns)) { + if (pattern.category === category) { + result[name] = pattern; + } + } + return result; +} + +/** + * Get auto-fixable patterns + * @returns {Object} Patterns with autoFix: true + */ +function getAutoFixablePatterns() { + const result = {}; + for (const [name, pattern] of Object.entries(agentPatterns)) { + if (pattern.autoFix) { + result[name] = pattern; + } + } + return result; +} + +module.exports = { + agentPatterns, + getAllPatterns, + getPatternsByCertainty, + getPatternsByCategory, + getAutoFixablePatterns +}; diff --git a/plugins/perf/lib/enhance/docs-analyzer.js b/plugins/perf/lib/enhance/docs-analyzer.js new file mode 100644 index 00000000..e927a68f --- /dev/null +++ b/plugins/perf/lib/enhance/docs-analyzer.js @@ -0,0 +1,325 @@ +/** + * Documentation Analyzer + * @author Avi Fenesh + * @license MIT + */ + +const fs = require('fs'); +const path = require('path'); +const { getPatternsForMode, estimateTokens } = require('./docs-patterns'); + +function analyzeDoc(docPath, options = {}) { + const { mode = 'both', verbose = false, existingFiles = [] } = options; + + const results = { + docName: path.basename(docPath, '.md'), + docPath, + mode, + tokenCount: 0, + linkIssues: [], + structureIssues: [], + codeIssues: [], + efficiencyIssues: [], + ragIssues: [], + balanceIssues: [] + }; + + // Read file + if (!fs.existsSync(docPath)) { + results.structureIssues.push({ + issue: 'File not found', + file: docPath, + certainty: 'HIGH', + patternId: 'file_not_found' + }); + return results; + } + + let content; + try { + content = fs.readFileSync(docPath, 'utf8'); + } catch (err) { + results.structureIssues.push({ + issue: `Failed to read file: ${err.message}`, + file: docPath, + certainty: 'HIGH', + patternId: 'read_error' + }); + return results; + } + + // Calculate token count + results.tokenCount = estimateTokens(content); + + // Get patterns applicable to this mode + const patterns = getPatternsForMode(mode); + + // Context for pattern checks + const context = { existingFiles }; + + // Run each pattern check + for (const [patternName, pattern] of Object.entries(patterns)) { + // Skip LOW certainty unless verbose + if (pattern.certainty === 'LOW' && !verbose) { + continue; + } + + // Run the check + const result = pattern.check(content, context); + + if (result) { + const issue = { + ...result, + file: docPath, + certainty: pattern.certainty, + patternId: pattern.id, + autoFix: pattern.autoFix + }; + + // Route to appropriate issue category + switch (pattern.category) { + case 'link': + results.linkIssues.push(issue); + break; + case 'structure': + results.structureIssues.push(issue); + break; + case 'code': + results.codeIssues.push(issue); + break; + case 'efficiency': + results.efficiencyIssues.push(issue); + break; + case 'rag': + results.ragIssues.push(issue); + break; + case 'balance': + results.balanceIssues.push(issue); + break; + default: + results.structureIssues.push(issue); + } + } + } + + return results; +} + +function analyzeAllDocs(docsDir, options = {}) { + const { recursive = true, ...analyzeOptions } = options; + const results = []; + + if (!fs.existsSync(docsDir)) { + return results; + } + + // Collect all markdown files + const mdFiles = []; + + function findMdFiles(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (err) { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory() && recursive) { + // Skip common non-doc directories + if (!['node_modules', '.git', 'dist', 'build'].includes(entry.name)) { + findMdFiles(fullPath); + } + } else if (entry.isFile() && entry.name.endsWith('.md')) { + // Skip README files in nested directories for agent-docs mode + if (options.mode === 'ai' && entry.name === 'README.md') { + continue; + } + mdFiles.push(fullPath); + } + } + } + + findMdFiles(docsDir); + + // Get relative paths for link validation + const existingFiles = mdFiles.map(f => path.relative(docsDir, f).replace(/\\/g, '/')); + + // Analyze each file + for (const mdFile of mdFiles) { + const result = analyzeDoc(mdFile, { ...analyzeOptions, existingFiles }); + results.push(result); + } + + return results; +} + +function analyze(options = {}) { + const { + doc, + docsDir = 'docs', + mode = 'both', + verbose = false + } = options; + + if (doc) { + // Check if doc is a directory or file + try { + const stats = fs.statSync(doc); + if (stats.isDirectory()) { + // Analyze all docs in directory + return analyzeAllDocs(doc, { mode, verbose }); + } else { + // Analyze single doc + return analyzeDoc(doc, { mode, verbose }); + } + } catch (err) { + // If file doesn't exist, let analyzeDoc handle the error + return analyzeDoc(doc, { mode, verbose }); + } + } else { + // Analyze all docs in directory + return analyzeAllDocs(docsDir, { mode, verbose }); + } +} + +function applyFixes(results, options = {}) { + // Collect all issues + let allIssues = []; + + if (Array.isArray(results)) { + for (const r of results) { + allIssues.push(...(r.linkIssues || [])); + allIssues.push(...(r.structureIssues || [])); + allIssues.push(...(r.codeIssues || [])); + allIssues.push(...(r.efficiencyIssues || [])); + allIssues.push(...(r.ragIssues || [])); + allIssues.push(...(r.balanceIssues || [])); + } + } else { + allIssues.push(...(results.linkIssues || [])); + allIssues.push(...(results.structureIssues || [])); + allIssues.push(...(results.codeIssues || [])); + allIssues.push(...(results.efficiencyIssues || [])); + allIssues.push(...(results.ragIssues || [])); + allIssues.push(...(results.balanceIssues || [])); + } + + // Filter to auto-fixable docs issues + const docsFixablePatternIds = [ + 'inconsistent_heading_levels', + 'verbose_explanations' + ]; + + const fixableIssues = allIssues.filter(i => + i.certainty === 'HIGH' && + i.autoFix && + docsFixablePatternIds.includes(i.patternId) + ); + + // Apply fixes using the fixer module's pattern + return applyDocsFixes(fixableIssues, options); +} + +function applyDocsFixes(issues, options = {}) { + const { dryRun = false, backup = true } = options; + const fixer = require('./fixer'); + + const results = { + applied: [], + skipped: [], + errors: [] + }; + + // Group by file + const byFile = new Map(); + for (const issue of issues) { + const fp = issue.file; + if (!byFile.has(fp)) { + byFile.set(fp, []); + } + byFile.get(fp).push(issue); + } + + // Process each file + for (const [filePath, fileIssues] of byFile) { + try { + if (!fs.existsSync(filePath)) { + results.errors.push({ filePath, error: 'File not found' }); + continue; + } + + let content = fs.readFileSync(filePath, 'utf8'); + const appliedToFile = []; + + for (const issue of fileIssues) { + try { + if (issue.patternId === 'inconsistent_heading_levels') { + content = fixer.fixInconsistentHeadings(content); + appliedToFile.push({ + issue: issue.issue, + fix: 'Fixed heading levels', + filePath + }); + } else if (issue.patternId === 'verbose_explanations') { + content = fixer.fixVerboseExplanations(content); + appliedToFile.push({ + issue: issue.issue, + fix: 'Simplified verbose phrases', + filePath + }); + } + } catch (err) { + results.errors.push({ + issue: issue.issue, + filePath, + error: err.message + }); + } + } + + // Write changes + if (!dryRun && appliedToFile.length > 0) { + if (backup) { + fs.writeFileSync(`${filePath}.backup`, fs.readFileSync(filePath, 'utf8'), 'utf8'); + } + fs.writeFileSync(filePath, content, 'utf8'); + } + + results.applied.push(...appliedToFile); + + } catch (err) { + results.errors.push({ + filePath, + error: err.message + }); + } + } + + return results; +} + +function generateReport(results, options = {}) { + const reporter = require('./reporter'); + + if (Array.isArray(results)) { + return reporter.generateDocsSummaryReport(results, options); + } else { + return reporter.generateDocsReport(results, options); + } +} + +const fixer = require('./fixer'); + +module.exports = { + analyzeDoc, + analyzeAllDocs, + analyze, + applyFixes, + applyDocsFixes, + fixInconsistentHeadings: fixer.fixInconsistentHeadings, + fixVerboseExplanations: fixer.fixVerboseExplanations, + generateReport +}; diff --git a/plugins/perf/lib/enhance/docs-patterns.js b/plugins/perf/lib/enhance/docs-patterns.js new file mode 100644 index 00000000..7af8d6da --- /dev/null +++ b/plugins/perf/lib/enhance/docs-patterns.js @@ -0,0 +1,671 @@ +/** + * Documentation Patterns + * @author Avi Fenesh + * @license MIT + */ + +function estimateTokens(text) { + if (!text || typeof text !== 'string') return 0; + return Math.ceil(text.length / 4); +} + +/** + * Supports modes: 'ai' (RAG optimized), 'both' (balanced), 'shared' (both) + */ +const docsPatterns = { + broken_internal_link: { + id: 'broken_internal_link', + category: 'link', + certainty: 'HIGH', + autoFix: false, + mode: 'shared', + description: 'Internal link references non-existent file or anchor', + check: (content, context = {}) => { + if (!content || typeof content !== 'string') return null; + + // Find markdown links + const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; + const brokenLinks = []; + let match; + + while ((match = linkRegex.exec(content)) !== null) { + const linkTarget = match[2]; + + // Skip external links + if (linkTarget.startsWith('http://') || linkTarget.startsWith('https://')) { + continue; + } + + // Check internal anchor links + if (linkTarget.startsWith('#')) { + const anchorId = linkTarget.slice(1).toLowerCase(); + // Generate expected heading anchors from content + const headings = content.match(/^#{1,6}\s+(.+)$/gm) || []; + const anchors = headings.map(h => { + return h.replace(/^#{1,6}\s+/, '') + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-'); + }); + + if (!anchors.includes(anchorId)) { + brokenLinks.push(linkTarget); + } + } + + // Note: File existence checks require context.existingFiles + // which is passed by the analyzer + if (context.existingFiles && !linkTarget.startsWith('#')) { + const targetPath = linkTarget.split('#')[0]; + if (!context.existingFiles.includes(targetPath)) { + brokenLinks.push(linkTarget); + } + } + } + + if (brokenLinks.length > 0) { + return { + issue: `Broken internal links: ${brokenLinks.slice(0, 3).join(', ')}${brokenLinks.length > 3 ? '...' : ''}`, + fix: 'Fix or remove broken links', + details: brokenLinks + }; + } + return null; + } + }, + + inconsistent_heading_levels: { + id: 'inconsistent_heading_levels', + category: 'structure', + certainty: 'HIGH', + autoFix: true, + mode: 'shared', + description: 'Heading levels skip (e.g., H1 to H3 without H2)', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + const headingRegex = /^(#{1,6})\s+/gm; + const levels = []; + let match; + + while ((match = headingRegex.exec(content)) !== null) { + levels.push(match[1].length); + } + + // Check for skipped levels + for (let i = 1; i < levels.length; i++) { + const jump = levels[i] - levels[i - 1]; + if (jump > 1) { + return { + issue: `Heading level jumps from H${levels[i - 1]} to H${levels[i]}`, + fix: 'Fix heading hierarchy to not skip levels' + }; + } + } + return null; + } + }, + + missing_code_language: { + id: 'missing_code_language', + category: 'code', + certainty: 'HIGH', + autoFix: false, + mode: 'shared', + description: 'Code block without language specification', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + // Find all code block starts + const allCodeBlocks = content.match(/```/g) || []; + // Count pairs (opening blocks) + const totalBlocks = Math.floor(allCodeBlocks.length / 2); + + if (totalBlocks === 0) return null; + + // Find code blocks with language (``` followed by non-whitespace) + const withLangRegex = /```[a-zA-Z][a-zA-Z0-9_-]*/g; + const withLang = content.match(withLangRegex) || []; + + const withoutLang = totalBlocks - withLang.length; + + if (withoutLang > 0) { + return { + issue: `${withoutLang} code block(s) without language specification`, + fix: 'Add language hint after ``` (e.g., ```javascript)' + }; + } + return null; + } + }, + + section_too_long: { + id: 'section_too_long', + category: 'structure', + certainty: 'MEDIUM', + autoFix: false, + mode: 'shared', + description: 'Section exceeds 1000 tokens (poor for RAG chunking)', + maxTokens: 1000, + check: (content) => { + if (!content || typeof content !== 'string') return null; + + // Split by headings + const sections = content.split(/^#{1,6}\s+/m); + const longSections = []; + + for (let i = 1; i < sections.length; i++) { + const section = sections[i]; + const tokens = estimateTokens(section); + if (tokens > 1000) { + // Get section title (first line) + const title = section.split('\n')[0].trim().slice(0, 50); + longSections.push({ title, tokens }); + } + } + + if (longSections.length > 0) { + return { + issue: `${longSections.length} section(s) exceed 1000 tokens`, + fix: 'Break long sections into smaller, focused subsections', + details: longSections + }; + } + return null; + } + }, + + unnecessary_prose: { + id: 'unnecessary_prose', + category: 'efficiency', + certainty: 'HIGH', + autoFix: false, + mode: 'ai', + description: 'Filler prose that adds no information value', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + // Patterns that indicate unnecessary prose + const prosePatterns = [ + /in this (?:document|section|guide)/gi, + /as you (?:can see|may know|probably know)/gi, + /it(?:'s| is) (?:important|worth noting) (?:to note |that )?/gi, + /please note that/gi, + /the following (?:section|document|guide) (?:will |provides )/gi, + /let(?:'s| us) (?:take a look|explore|dive into)/gi, + /we(?:'ll| will) (?:cover|discuss|explore)/gi, + /this allows you to/gi, + /you(?:'ll| will) (?:learn|discover|find)/gi, + /as mentioned (?:earlier|above|before)/gi + ]; + + const found = []; + for (const pattern of prosePatterns) { + const matches = content.match(pattern); + if (matches) { + found.push(...matches.slice(0, 2)); + } + } + + if (found.length >= 3) { + return { + issue: `Found ${found.length} instances of unnecessary prose`, + fix: 'Remove filler text - state facts directly', + details: found.slice(0, 5) + }; + } + return null; + } + }, + + verbose_explanations: { + id: 'verbose_explanations', + category: 'efficiency', + certainty: 'HIGH', + autoFix: true, + mode: 'ai', + description: 'Verbose explanations that could be condensed', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + // Detect verbose patterns + const verbosePatterns = [ + { pattern: /\bin order to\b/gi, replacement: 'to' }, + { pattern: /\bfor the purpose of\b/gi, replacement: 'for' }, + { pattern: /\bin the event that\b/gi, replacement: 'if' }, + { pattern: /\bat this point in time\b/gi, replacement: 'now' }, + { pattern: /\bdue to the fact that\b/gi, replacement: 'because' }, + { pattern: /\bhas the ability to\b/gi, replacement: 'can' }, + { pattern: /\bis able to\b/gi, replacement: 'can' }, + { pattern: /\bmake use of\b/gi, replacement: 'use' }, + { pattern: /\ba large number of\b/gi, replacement: 'many' }, + { pattern: /\ba small number of\b/gi, replacement: 'few' }, + { pattern: /\bthe majority of\b/gi, replacement: 'most' }, + { pattern: /\bprior to\b/gi, replacement: 'before' }, + { pattern: /\bsubsequent to\b/gi, replacement: 'after' } + ]; + + const found = []; + for (const { pattern } of verbosePatterns) { + const matches = content.match(pattern); + if (matches) { + found.push(...matches); + } + } + + if (found.length >= 3) { + return { + issue: `Found ${found.length} verbose phrases that could be simplified`, + fix: 'Replace verbose phrases with concise alternatives', + details: found.slice(0, 5) + }; + } + return null; + } + }, + + suboptimal_chunking: { + id: 'suboptimal_chunking', + category: 'rag', + certainty: 'MEDIUM', + autoFix: false, + mode: 'ai', + description: 'Content structure suboptimal for RAG chunking', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + const issues = []; + + // Check for very few headings in long content + const tokens = estimateTokens(content); + const headingCount = (content.match(/^#{1,6}\s+/gm) || []).length; + + if (tokens > 500 && headingCount < Math.floor(tokens / 500)) { + issues.push('Too few section headings for content length'); + } + + // Check for headings without content + const sections = content.split(/^#{1,6}\s+/m); + for (let i = 1; i < sections.length; i++) { + const sectionContent = sections[i].split(/^#{1,6}\s+/m)[0]; + if (estimateTokens(sectionContent) < 20) { + issues.push('Some sections have very little content'); + break; + } + } + + if (issues.length > 0) { + return { + issue: issues.join('; '), + fix: 'Restructure content with consistent section sizes (200-500 tokens)' + }; + } + return null; + } + }, + + poor_semantic_boundaries: { + id: 'poor_semantic_boundaries', + category: 'rag', + certainty: 'MEDIUM', + autoFix: false, + mode: 'ai', + description: 'Section mixes multiple distinct topics', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + // Look for transition words that suggest topic changes within sections + const transitionPatterns = [ + /\n\n(?:additionally|also|furthermore|moreover|on another note|separately)/gi, + /\n\n(?:however|on the other hand|alternatively|in contrast)/gi, + /\n\n(?:next|then|finally|lastly|another (?:thing|point|topic))/gi + ]; + + // Split into sections and check each + const sections = content.split(/^#{1,6}\s+/m); + let problemSections = 0; + + for (const section of sections) { + let transitionsInSection = 0; + for (const pattern of transitionPatterns) { + const matches = section.match(pattern); + if (matches) transitionsInSection += matches.length; + } + + if (transitionsInSection >= 3) { + problemSections++; + } + } + + if (problemSections > 0) { + return { + issue: `${problemSections} section(s) may mix multiple topics`, + fix: 'Split sections so each covers a single, focused topic' + }; + } + return null; + } + }, + + missing_context_anchors: { + id: 'missing_context_anchors', + category: 'rag', + certainty: 'MEDIUM', + autoFix: false, + mode: 'ai', + description: 'Sections lack self-contained context for RAG retrieval', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + // Check for dangling pronouns at section starts + const sections = content.split(/^#{1,6}\s+/m); + const issues = []; + + for (let i = 1; i < sections.length; i++) { + const section = sections[i]; + const lines = section.split('\n').filter(l => l.trim()); + + if (lines.length > 1) { + // First content line after heading + const firstLine = lines[1] || ''; + + // Check if starts with dangling reference + if (/^(?:It|This|These|Those|They|The above|As mentioned)\s/i.test(firstLine)) { + const title = lines[0].slice(0, 30); + issues.push(title); + } + } + } + + if (issues.length >= 2) { + return { + issue: `${issues.length} sections start with context-dependent references`, + fix: 'Make each section self-contained (avoid "It", "This" without context)', + details: issues.slice(0, 3) + }; + } + return null; + } + }, + + token_inefficiency_suggestions: { + id: 'token_inefficiency_suggestions', + category: 'efficiency', + certainty: 'LOW', + autoFix: false, + mode: 'ai', + description: 'Suggestions for reducing token usage', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + const suggestions = []; + const tokens = estimateTokens(content); + + // Check for repeated phrases + const words = content.toLowerCase().split(/\s+/); + const phrases = {}; + for (let i = 0; i < words.length - 2; i++) { + const phrase = words.slice(i, i + 3).join(' '); + phrases[phrase] = (phrases[phrase] || 0) + 1; + } + + const repeatedPhrases = Object.entries(phrases) + .filter(([_, count]) => count >= 4) + .map(([phrase]) => phrase); + + if (repeatedPhrases.length > 0) { + suggestions.push(`Repeated phrases could be consolidated: ${repeatedPhrases.slice(0, 2).join(', ')}`); + } + + // Check for very long lists that could be tables + const longLists = content.match(/(?:^[-*]\s+.+\n){10,}/gm); + if (longLists) { + suggestions.push('Long lists (10+ items) might be more efficient as tables'); + } + + // Check token density + const lineCount = content.split('\n').length; + if (lineCount > 0 && tokens / lineCount < 3) { + suggestions.push('Many short lines - consider consolidating'); + } + + if (suggestions.length > 0) { + return { + issue: `Token efficiency suggestions (current: ~${tokens} tokens)`, + fix: suggestions.join('; ') + }; + } + return null; + } + }, + + missing_section_headers: { + id: 'missing_section_headers', + category: 'structure', + certainty: 'MEDIUM', + autoFix: false, + mode: 'both', + description: 'Long content blocks without section headers', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + // Find paragraphs (content between headings or start/end) + const parts = content.split(/^#{1,6}\s+/m); + const longBlocks = []; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + // First part (before any heading) should have heading line removed + // Subsequent parts have the heading text as first line, which we keep for token count + const cleanPart = i === 0 ? part : part.split('\n').slice(1).join('\n'); + const tokens = estimateTokens(cleanPart); + + if (tokens > 500) { + const preview = cleanPart.trim().split('\n')[0].slice(0, 50); + longBlocks.push({ tokens, preview }); + } + } + + if (longBlocks.length > 0) { + return { + issue: `${longBlocks.length} content block(s) over 500 tokens without sub-headers`, + fix: 'Add section headers to break up long content', + details: longBlocks + }; + } + return null; + } + }, + + poor_context_ordering: { + id: 'poor_context_ordering', + category: 'structure', + certainty: 'MEDIUM', + autoFix: false, + mode: 'both', + description: 'Important information may be buried too deep', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + // Check if critical keywords appear late in document + const criticalKeywords = [ + /\b(?:important|critical|must|required|warning|caution|danger)\b/i, + /\b(?:error|fail|break|crash|security|vulnerability)\b/i + ]; + + const lines = content.split('\n'); + const totalLines = lines.length; + const lateThreshold = Math.floor(totalLines * 0.7); + + const lateImportantLines = []; + + for (let i = lateThreshold; i < totalLines; i++) { + for (const pattern of criticalKeywords) { + if (pattern.test(lines[i])) { + lateImportantLines.push(lines[i].trim().slice(0, 50)); + break; + } + } + } + + if (lateImportantLines.length >= 3) { + return { + issue: 'Critical information appears in the last 30% of document', + fix: 'Move important warnings/requirements earlier in the document' + }; + } + return null; + } + }, + + readability_with_rag_suggestions: { + id: 'readability_with_rag_suggestions', + category: 'balance', + certainty: 'LOW', + autoFix: false, + mode: 'both', + description: 'Suggestions for balancing readability and RAG optimization', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + const suggestions = []; + + // Check for very short paragraphs (good for RAG, but might hurt readability) + const paragraphs = content.split(/\n\n+/).filter(p => p.trim()); + const veryShort = paragraphs.filter(p => estimateTokens(p) < 20).length; + + if (veryShort > paragraphs.length * 0.5) { + suggestions.push('Many very short paragraphs - consider grouping related points'); + } + + // Check for lack of explanatory text (good for AI, bad for humans) + const hasExamples = /```||for example|e\.g\./i.test(content); + const hasExplanation = /because|since|therefore|this means/i.test(content); + + if (hasExamples && !hasExplanation) { + suggestions.push('Examples present but limited explanation - add context for human readers'); + } + + // Check for dense technical content without summaries + const codeBlocks = (content.match(/```[\s\S]*?```/g) || []).length; + const hasSummary = /^##?\s+(?:summary|overview|tldr|key points)/im.test(content); + + if (codeBlocks >= 5 && !hasSummary) { + suggestions.push('Dense code content - consider adding a summary section'); + } + + if (suggestions.length > 0) { + return { + issue: 'Balance suggestions for readability vs RAG optimization', + fix: suggestions.join('; ') + }; + } + return null; + } + }, + + structure_recommendations: { + id: 'structure_recommendations', + category: 'structure', + certainty: 'LOW', + autoFix: false, + mode: 'both', + description: 'General structure recommendations', + check: (content) => { + if (!content || typeof content !== 'string') return null; + + const recommendations = []; + + // Check for table of contents in long documents + const tokens = estimateTokens(content); + const headingCount = (content.match(/^#{1,6}\s+/gm) || []).length; + const hasToc = /^##?\s+(?:table of contents|contents|toc)/im.test(content) || + /^\s*-\s+\[.+\]\(#/m.test(content); + + if (tokens > 2000 && headingCount >= 5 && !hasToc) { + recommendations.push('Consider adding a table of contents for navigation'); + } + + // Check for missing introduction + const firstHeading = content.match(/^#{1,6}\s+(.+)/m); + const hasIntro = /^##?\s+(?:introduction|overview|about|getting started)/im.test(content); + + if (tokens > 1000 && firstHeading && !hasIntro) { + recommendations.push('Consider adding an introduction or overview section'); + } + + // Check for consistent formatting + const bulletStyles = { + dash: (content.match(/^-\s+/gm) || []).length, + asterisk: (content.match(/^\*\s+/gm) || []).length + }; + + if (bulletStyles.dash > 0 && bulletStyles.asterisk > 0) { + recommendations.push('Mixed bullet styles (- and *) - consider using one consistently'); + } + + if (recommendations.length > 0) { + return { + issue: 'Structure recommendations', + fix: recommendations.join('; ') + }; + } + return null; + } + } +}; + +function getAllPatterns() { + return docsPatterns; +} + +function getPatternsByMode(mode) { + const result = {}; + for (const [name, pattern] of Object.entries(docsPatterns)) { + if (pattern.mode === mode || pattern.mode === 'shared') { + result[name] = pattern; + } + } + return result; +} + +function getPatternsByCertainty(certainty) { + const result = {}; + for (const [name, pattern] of Object.entries(docsPatterns)) { + if (pattern.certainty === certainty) { + result[name] = pattern; + } + } + return result; +} + +function getPatternsByCategory(category) { + const result = {}; + for (const [name, pattern] of Object.entries(docsPatterns)) { + if (pattern.category === category) { + result[name] = pattern; + } + } + return result; +} + +function getAutoFixablePatterns() { + const result = {}; + for (const [name, pattern] of Object.entries(docsPatterns)) { + if (pattern.autoFix) { + result[name] = pattern; + } + } + return result; +} + +module.exports = { + docsPatterns, + estimateTokens, + getAllPatterns, + getPatternsByMode, + getPatternsByCertainty, + getPatternsByCategory, + getAutoFixablePatterns, + getPatternsForMode: getPatternsByMode +}; diff --git a/plugins/perf/lib/enhance/fixer.js b/plugins/perf/lib/enhance/fixer.js new file mode 100644 index 00000000..d68d7653 --- /dev/null +++ b/plugins/perf/lib/enhance/fixer.js @@ -0,0 +1,468 @@ +/** + * Plugin Analysis Fixer + * @author Avi Fenesh + * @license MIT + */ + +const fs = require('fs'); +const path = require('path'); + +function applyFixes(issues, options = {}) { + const { dryRun = false, backup = true } = options; + + const results = { + applied: [], + skipped: [], + errors: [] + }; + + // Auto-fixable pattern IDs for markdown files (agent analysis) + const markdownAutoFixPatternIds = [ + 'missing_frontmatter', + 'unrestricted_bash', + 'missing_role' + ]; + + // Filter to only HIGH certainty issues that are auto-fixable + // Includes: JSON issues with autoFixFn OR markdown issues with known pattern IDs + const fixableIssues = issues.filter(i => + i.certainty === 'HIGH' && + (i.filePath || i.file) && + (i.autoFixFn || markdownAutoFixPatternIds.includes(i.patternId)) + ); + + // Group by file to minimize reads/writes + const byFile = new Map(); + for (const issue of fixableIssues) { + const fp = issue.filePath || issue.file; + if (!byFile.has(fp)) { + byFile.set(fp, []); + } + byFile.get(fp).push(issue); + } + + // Process each file + for (const [filePath, fileIssues] of byFile) { + try { + // Read current content + if (!fs.existsSync(filePath)) { + results.errors.push({ filePath, error: 'File not found' }); + continue; + } + + const content = fs.readFileSync(filePath, 'utf8'); + let data; + + // Parse based on file type + if (filePath.endsWith('.json')) { + data = JSON.parse(content); + } else if (filePath.endsWith('.md')) { + // Markdown files - handle specially + data = content; + } else { + // For other files, skip auto-fix + results.skipped.push(...fileIssues.map(i => ({ + ...i, + reason: 'Unsupported file type - manual fix required' + }))); + continue; + } + + // Apply each fix + let modified = data; + const appliedToFile = []; + + for (const issue of fileIssues) { + try { + // Determine what part of data to fix + if (filePath.endsWith('.md')) { + // Markdown-specific fixes + if (issue.patternId === 'missing_frontmatter') { + modified = fixMissingFrontmatter(modified); + } else if (issue.patternId === 'unrestricted_bash') { + modified = fixUnrestrictedBash(modified); + } else if (issue.patternId === 'missing_role') { + modified = fixMissingRole(modified); + } else { + // No auto-fix available for this markdown issue + continue; + } + } else if (issue.schemaPath) { + // Fix at specific path in the data + modified = applyAtPath(modified, issue.schemaPath, issue.autoFixFn); + } else { + // Apply to root + modified = issue.autoFixFn(modified); + } + + appliedToFile.push({ + issue: issue.issue, + fix: issue.fix, + filePath + }); + } catch (err) { + results.errors.push({ + issue: issue.issue, + filePath, + error: err.message + }); + } + } + + // Write changes + if (!dryRun && appliedToFile.length > 0) { + // Create backup + if (backup) { + const backupPath = `${filePath}.backup`; + fs.writeFileSync(backupPath, content, 'utf8'); + } + + // Write modified content + let newContent; + if (filePath.endsWith('.md')) { + newContent = modified; // Already a string + } else { + newContent = JSON.stringify(modified, null, 2); + } + fs.writeFileSync(filePath, newContent, 'utf8'); + } + + results.applied.push(...appliedToFile); + + } catch (err) { + results.errors.push({ + filePath, + error: err.message + }); + } + } + + // Add non-fixable issues to skipped + const nonFixable = issues.filter(i => + i.certainty !== 'HIGH' || !markdownAutoFixPatternIds.includes(i.patternId) + ); + results.skipped.push(...nonFixable.map(i => ({ + ...i, + reason: i.certainty !== 'HIGH' ? 'Not HIGH certainty' : 'No auto-fix available for this pattern' + }))); + + return results; +} + +function applyAtPath(obj, pathStr, fixFn) { + const parts = pathStr.split('.'); + const result = JSON.parse(JSON.stringify(obj)); // Deep clone + + let current = result; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (part.includes('[')) { + // Array access + const match = part.match(/(\w+)\[(\d+)\]/); + if (match) { + current = current[match[1]][parseInt(match[2])]; + } + } else { + current = current[part]; + } + } + + const lastPart = parts[parts.length - 1]; + if (lastPart.includes('[')) { + const match = lastPart.match(/(\w+)\[(\d+)\]/); + if (match) { + current[match[1]][parseInt(match[2])] = fixFn(current[match[1]][parseInt(match[2])]); + } + } else { + current[lastPart] = fixFn(current[lastPart]); + } + + return result; +} + +function fixAdditionalProperties(schema) { + if (!schema || typeof schema !== 'object') return schema; + + const fixed = { ...schema }; + + if (fixed.type === 'object' && fixed.properties) { + fixed.additionalProperties = false; + } + + // Recursively fix nested schemas + if (fixed.properties) { + fixed.properties = {}; + for (const [key, value] of Object.entries(schema.properties)) { + fixed.properties[key] = fixAdditionalProperties(value); + } + } + + return fixed; +} + +function fixRequiredFields(schema) { + if (!schema || typeof schema !== 'object') return schema; + + const fixed = { ...schema }; + + if (fixed.type === 'object' && fixed.properties && !fixed.required) { + // Add all non-optional fields to required + fixed.required = Object.entries(fixed.properties) + .filter(([_, prop]) => { + // Skip if has default or marked optional in description + if (prop.default !== undefined) return false; + if (prop.description && /optional/i.test(prop.description)) return false; + return true; + }) + .map(([key]) => key); + } + + return fixed; +} + +function fixVersionMismatch(pluginJson, targetVersion) { + return { + ...pluginJson, + version: targetVersion + }; +} + +function previewFixes(issues) { + const previews = []; + + for (const issue of issues) { + if (issue.certainty === 'HIGH' && issue.autoFixFn) { + previews.push({ + filePath: issue.filePath, + issue: issue.issue, + fix: issue.fix, + willApply: true + }); + } else { + previews.push({ + filePath: issue.filePath, + issue: issue.issue, + fix: issue.fix || 'No auto-fix available', + willApply: false, + reason: issue.certainty !== 'HIGH' ? 'Not HIGH certainty' : 'No auto-fix function' + }); + } + } + + return previews; +} + +function restoreFromBackup(filePath) { + const backupPath = `${filePath}.backup`; + + if (!fs.existsSync(backupPath)) { + return false; + } + + const backupContent = fs.readFileSync(backupPath, 'utf8'); + fs.writeFileSync(filePath, backupContent, 'utf8'); + fs.unlinkSync(backupPath); + + return true; +} + +function cleanupBackups(directory) { + let count = 0; + + function findBackups(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (err) { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + findBackups(fullPath); + } else if (entry.isFile() && entry.name.endsWith('.backup')) { + try { + fs.unlinkSync(fullPath); + count++; + } catch (err) { + } + } + } + } + + findBackups(directory); + return count; +} + +function fixMissingFrontmatter(content) { + if (!content || typeof content !== 'string') return content; + + const template = `--- +name: agent-name +description: Agent description +tools: Read, Glob, Grep +model: sonnet +--- + +`; + + return template + content.trim(); +} + +function fixUnrestrictedBash(content) { + if (!content || typeof content !== 'string') return content; + + const lines = content.split('\n'); + let inFrontmatter = false; + + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim() === '---') { + if (!inFrontmatter) { + inFrontmatter = true; + } else { + break; + } + } else if (inFrontmatter && lines[i].startsWith('tools:')) { + lines[i] = lines[i].replace(/\bBash\b(?!\()/g, 'Bash(git:*)'); + } + } + + return lines.join('\n'); +} + +function fixMissingRole(content) { + if (!content || typeof content !== 'string') return content; + + const lines = content.split('\n'); + let frontmatterEnd = -1; + let inFrontmatter = false; + + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim() === '---') { + if (!inFrontmatter) { + inFrontmatter = true; + } else { + frontmatterEnd = i; + break; + } + } + } + + const roleSection = ` +## Your Role + +You are an agent that [describe agent purpose]. +`; + + if (frontmatterEnd >= 0) { + lines.splice(frontmatterEnd + 1, 0, roleSection); + } else { + lines.unshift(roleSection); + } + + return lines.join('\n'); +} + +function fixInconsistentHeadings(content) { + if (!content || typeof content !== 'string') return content; + + const lines = content.split('\n'); + let lastLevel = 0; + let inCodeBlock = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (line.startsWith('```')) { + inCodeBlock = !inCodeBlock; + continue; + } + + if (inCodeBlock) continue; + + const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + const currentLevel = headingMatch[1].length; + const headingText = headingMatch[2]; + + if (lastLevel === 0) { + lastLevel = currentLevel; + continue; + } + + // If jumping more than one level down, fix it + if (currentLevel > lastLevel + 1) { + const fixedLevel = lastLevel + 1; + lines[i] = '#'.repeat(fixedLevel) + ' ' + headingText; + lastLevel = fixedLevel; + } else { + lastLevel = currentLevel; + } + } + } + + return lines.join('\n'); +} + +function fixVerboseExplanations(content) { + if (!content || typeof content !== 'string') return content; + + const replacements = [ + { from: /\bin order to\b/gi, to: 'to' }, + { from: /\bfor the purpose of\b/gi, to: 'for' }, + { from: /\bin the event that\b/gi, to: 'if' }, + { from: /\bat this point in time\b/gi, to: 'now' }, + { from: /\bdue to the fact that\b/gi, to: 'because' }, + { from: /\bhas the ability to\b/gi, to: 'can' }, + { from: /\bis able to\b/gi, to: 'can' }, + { from: /\bmake use of\b/gi, to: 'use' }, + { from: /\ba large number of\b/gi, to: 'many' }, + { from: /\ba small number of\b/gi, to: 'few' }, + { from: /\bthe majority of\b/gi, to: 'most' }, + { from: /\bprior to\b/gi, to: 'before' }, + { from: /\bsubsequent to\b/gi, to: 'after' } + ]; + + let result = content; + + const codeBlockRegex = /```[\s\S]*?```/g; + const codeBlocks = []; + let placeholder = 0; + + result = result.replace(codeBlockRegex, (match) => { + codeBlocks.push(match); + return `__CODE_BLOCK_${placeholder++}__`; + }); + + for (const { from, to } of replacements) { + result = result.replace(from, (match) => { + // Preserve case of first character + if (match[0] === match[0].toUpperCase()) { + return to[0].toUpperCase() + to.slice(1); + } + return to; + }); + } + + for (let i = 0; i < codeBlocks.length; i++) { + result = result.replace(`__CODE_BLOCK_${i}__`, codeBlocks[i]); + } + + return result; +} + +module.exports = { + applyFixes, + fixAdditionalProperties, + fixRequiredFields, + fixVersionMismatch, + fixMissingFrontmatter, + fixUnrestrictedBash, + fixMissingRole, + fixInconsistentHeadings, + fixVerboseExplanations, + previewFixes, + restoreFromBackup, + cleanupBackups +}; diff --git a/plugins/perf/lib/enhance/hook-analyzer.js b/plugins/perf/lib/enhance/hook-analyzer.js new file mode 100644 index 00000000..2530e111 --- /dev/null +++ b/plugins/perf/lib/enhance/hook-analyzer.js @@ -0,0 +1,135 @@ +/** + * Hook analyzer for /enhance. + */ + +const fs = require('fs'); +const path = require('path'); +const { hookPatterns } = require('./hook-patterns'); +const { parseMarkdownFrontmatter } = require('./agent-analyzer'); + +function analyzeHook(hookPath) { + const results = { + hookName: path.basename(hookPath, '.md'), + hookPath, + structureIssues: [] + }; + + if (!fs.existsSync(hookPath)) { + results.structureIssues.push({ + issue: 'File not found', + file: hookPath, + certainty: 'HIGH', + patternId: 'file_not_found' + }); + return results; + } + + let content = ''; + try { + content = fs.readFileSync(hookPath, 'utf8'); + } catch (err) { + results.structureIssues.push({ + issue: `Failed to read file: ${err.message}`, + file: hookPath, + certainty: 'HIGH', + patternId: 'read_error' + }); + return results; + } + + const missingFm = hookPatterns.missing_frontmatter.check(content); + if (missingFm) { + results.structureIssues.push({ + ...missingFm, + file: hookPath, + certainty: hookPatterns.missing_frontmatter.certainty, + patternId: hookPatterns.missing_frontmatter.id + }); + } + + const { frontmatter } = parseMarkdownFrontmatter(content); + const missingName = hookPatterns.missing_name.check(frontmatter); + if (missingName) { + results.structureIssues.push({ + ...missingName, + file: hookPath, + certainty: hookPatterns.missing_name.certainty, + patternId: hookPatterns.missing_name.id + }); + } + + const missingDescription = hookPatterns.missing_description.check(frontmatter); + if (missingDescription) { + results.structureIssues.push({ + ...missingDescription, + file: hookPath, + certainty: hookPatterns.missing_description.certainty, + patternId: hookPatterns.missing_description.id + }); + } + + return results; +} + +function analyzeAllHooks(hooksDir) { + const results = []; + if (!fs.existsSync(hooksDir)) return results; + + const hookFiles = []; + const skipDirs = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'target']); + + function walk(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (err) { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!skipDirs.has(entry.name)) { + walk(fullPath); + } + continue; + } + + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + const parts = fullPath.split(path.sep); + if (parts.includes('hooks')) { + hookFiles.push(fullPath); + } + } + } + + walk(hooksDir); + + for (const file of hookFiles) { + results.push(analyzeHook(file)); + } + + return results; +} + +function analyze(options = {}) { + const { + hook, + hooksDir = 'plugins/enhance/hooks' + } = options; + + if (hook) { + const hookPath = hook.endsWith('.md') + ? hook + : path.join(hooksDir, `${hook}.md`); + return analyzeHook(hookPath); + } + + return analyzeAllHooks(hooksDir); +} + +module.exports = { + analyzeHook, + analyzeAllHooks, + analyze +}; diff --git a/plugins/perf/lib/enhance/hook-patterns.js b/plugins/perf/lib/enhance/hook-patterns.js new file mode 100644 index 00000000..472c789b --- /dev/null +++ b/plugins/perf/lib/enhance/hook-patterns.js @@ -0,0 +1,40 @@ +/** + * Hook patterns for /enhance. + */ + +const hookPatterns = { + missing_frontmatter: { + id: 'missing_frontmatter', + certainty: 'HIGH', + check(content) { + if (!content || !content.trim().startsWith('---')) { + return { issue: 'Missing YAML frontmatter in hook file' }; + } + return null; + } + }, + missing_name: { + id: 'missing_name', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.name) { + return { issue: 'Missing name in hook frontmatter' }; + } + return null; + } + }, + missing_description: { + id: 'missing_description', + certainty: 'HIGH', + check(frontmatter) { + if (!frontmatter || !frontmatter.description) { + return { issue: 'Missing description in hook frontmatter' }; + } + return null; + } + } +}; + +module.exports = { + hookPatterns +}; diff --git a/plugins/perf/lib/enhance/index.js b/plugins/perf/lib/enhance/index.js new file mode 100644 index 00000000..07539241 --- /dev/null +++ b/plugins/perf/lib/enhance/index.js @@ -0,0 +1,92 @@ +/** + * Enhance Library + * @author Avi Fenesh + * @license MIT + */ + +const pluginAnalyzer = require('./plugin-analyzer'); +const pluginPatterns = require('./plugin-patterns'); +const toolPatterns = require('./tool-patterns'); +const securityPatterns = require('./security-patterns'); +const agentAnalyzer = require('./agent-analyzer'); +const agentPatterns = require('./agent-patterns'); +const docsAnalyzer = require('./docs-analyzer'); +const docsPatterns = require('./docs-patterns'); +const projectmemoryAnalyzer = require('./projectmemory-analyzer'); +const projectmemoryPatterns = require('./projectmemory-patterns'); +const promptAnalyzer = require('./prompt-analyzer'); +const promptPatterns = require('./prompt-patterns'); +const hookAnalyzer = require('./hook-analyzer'); +const skillAnalyzer = require('./skill-analyzer'); +const reporter = require('./reporter'); +const fixer = require('./fixer'); + +module.exports = { + // Main analyzers + pluginAnalyzer, + agentAnalyzer, + docsAnalyzer, + projectmemoryAnalyzer, + promptAnalyzer, + hookAnalyzer, + skillAnalyzer, + + // Pattern modules + pluginPatterns, + toolPatterns, + securityPatterns, + agentPatterns, + docsPatterns, + projectmemoryPatterns, + promptPatterns, + + // Output modules + reporter, + fixer, + + // Convenience exports - Plugin + analyze: pluginAnalyzer.analyze, + analyzePlugin: pluginAnalyzer.analyzePlugin, + analyzeAllPlugins: pluginAnalyzer.analyzeAllPlugins, + applyFixes: pluginAnalyzer.applyFixes, + generateReport: pluginAnalyzer.generateReport, + + // Convenience exports - Agent + analyzeAgent: agentAnalyzer.analyzeAgent, + analyzeAllAgents: agentAnalyzer.analyzeAllAgents, + agentApplyFixes: agentAnalyzer.applyFixes, + agentGenerateReport: agentAnalyzer.generateReport, + + // Convenience exports - Docs + analyzeDoc: docsAnalyzer.analyzeDoc, + analyzeAllDocs: docsAnalyzer.analyzeAllDocs, + docsApplyFixes: docsAnalyzer.applyFixes, + docsGenerateReport: docsAnalyzer.generateReport, + + // Convenience exports - Project Memory (CLAUDE.md/AGENTS.md) + analyzeProjectMemory: projectmemoryAnalyzer.analyze, + analyzeClaudeMd: projectmemoryAnalyzer.analyze, // Alias for familiarity + findProjectMemoryFile: projectmemoryAnalyzer.findProjectMemoryFile, + projectMemoryApplyFixes: projectmemoryAnalyzer.applyFixes, + projectMemoryGenerateReport: projectmemoryAnalyzer.generateReport, + + // Convenience exports - Prompt + analyzePrompt: promptAnalyzer.analyzePrompt, + analyzeAllPrompts: promptAnalyzer.analyzeAllPrompts, + promptApplyFixes: promptAnalyzer.applyFixes, + promptGenerateReport: promptAnalyzer.generateReport, + + // Convenience exports - Hooks + analyzeHook: hookAnalyzer.analyzeHook, + analyzeAllHooks: hookAnalyzer.analyzeAllHooks, + hooksAnalyze: hookAnalyzer.analyze, + + // Convenience exports - Skills + analyzeSkill: skillAnalyzer.analyzeSkill, + analyzeAllSkills: skillAnalyzer.analyzeAllSkills, + skillsAnalyze: skillAnalyzer.analyze, + + // Convenience exports - Orchestrator + generateOrchestratorReport: reporter.generateOrchestratorReport, + deduplicateOrchestratorFindings: reporter.deduplicateOrchestratorFindings +}; diff --git a/plugins/perf/lib/enhance/plugin-analyzer.js b/plugins/perf/lib/enhance/plugin-analyzer.js new file mode 100644 index 00000000..3a333a5a --- /dev/null +++ b/plugins/perf/lib/enhance/plugin-analyzer.js @@ -0,0 +1,402 @@ +/** + * Plugin Analyzer + * Main orchestrator for plugin structure and tool use analysis + * + * @author Avi Fenesh + * @license MIT + */ + +const fs = require('fs'); +const path = require('path'); +const pluginPatterns = require('./plugin-patterns'); +const toolPatterns = require('./tool-patterns'); +const securityPatterns = require('./security-patterns'); +const reporter = require('./reporter'); +const fixer = require('./fixer'); + +/** + * Find nearest package.json by walking up directory tree + * @param {string} startPath - Starting directory path + * @param {number} maxLevels - Maximum levels to traverse (default: 5) + * @returns {string|null} Path to package.json or null if not found + */ +function findNearestPackageJson(startPath, maxLevels = 5) { + let currentPath = path.resolve(startPath); + + for (let i = 0; i < maxLevels; i++) { + const packageJsonPath = path.join(currentPath, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + return packageJsonPath; + } + + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) { + // Reached root + break; + } + currentPath = parentPath; + } + + return null; +} + +/** + * Analyze a single plugin + * @param {string} pluginPath - Path to plugin directory + * @param {Object} options - Analysis options + * @param {boolean} options.verbose - Include LOW certainty issues + * @returns {Object} Analysis results + */ +async function analyzePlugin(pluginPath, options = {}) { + const results = { + pluginName: path.basename(pluginPath), + pluginPath, + filesScanned: 0, + toolIssues: [], + structureIssues: [], + securityIssues: [] + }; + + // Find plugin.json + const pluginJsonPath = path.join(pluginPath, '.claude-plugin', 'plugin.json'); + const altPluginJsonPath = path.join(pluginPath, 'plugin.json'); + + let pluginJson = null; + let pluginJsonFile = null; + + if (fs.existsSync(pluginJsonPath)) { + try { + pluginJson = JSON.parse(fs.readFileSync(pluginJsonPath, 'utf8')); + pluginJsonFile = pluginJsonPath; + results.filesScanned++; + } catch (err) { + results.structureIssues.push({ + issue: 'Failed to parse plugin.json', + file: pluginJsonPath, + detail: err.message, + certainty: 'HIGH', + patternId: 'malformed_plugin_json' + }); + } + } else if (fs.existsSync(altPluginJsonPath)) { + try { + pluginJson = JSON.parse(fs.readFileSync(altPluginJsonPath, 'utf8')); + pluginJsonFile = altPluginJsonPath; + results.filesScanned++; + } catch (err) { + results.structureIssues.push({ + issue: 'Failed to parse plugin.json', + file: altPluginJsonPath, + detail: err.message, + certainty: 'HIGH', + patternId: 'malformed_plugin_json' + }); + } + } + + // Check package.json for version comparison (walk up to find it) + const packageJsonPath = findNearestPackageJson(pluginPath); + let packageJson = null; + if (packageJsonPath) { + try { + packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + } catch (err) { + // Non-critical - version comparison will just be skipped + } + } + + // Analyze plugin.json structure + if (pluginJson) { + results.pluginName = pluginJson.name || results.pluginName; + + // Check required fields + const reqFieldsPattern = pluginPatterns.pluginPatterns.missing_required_plugin_fields; + const reqResult = reqFieldsPattern.check(pluginJson); + if (reqResult) { + results.structureIssues.push({ + ...reqResult, + file: pluginJsonFile, + certainty: reqFieldsPattern.certainty, + patternId: reqFieldsPattern.id + }); + } + + // Check version format + const versionPattern = pluginPatterns.pluginPatterns.invalid_version_format; + const versionResult = versionPattern.check(pluginJson); + if (versionResult) { + results.structureIssues.push({ + ...versionResult, + file: pluginJsonFile, + certainty: versionPattern.certainty, + patternId: versionPattern.id + }); + } + + // Check version mismatch + if (packageJson) { + const mismatchPattern = pluginPatterns.pluginPatterns.version_mismatch; + const mismatchResult = mismatchPattern.check(pluginJson, packageJson); + if (mismatchResult) { + results.structureIssues.push({ + ...mismatchResult, + file: pluginJsonFile, + filePath: pluginJsonFile, + certainty: mismatchPattern.certainty, + patternId: mismatchPattern.id, + autoFixFn: (pj) => fixer.fixVersionMismatch(pj, packageJson.version) + }); + } + } + + // Check tool overexposure + const overexposurePattern = pluginPatterns.pluginPatterns.tool_overexposure; + const overexposureResult = overexposurePattern.check(pluginJson); + if (overexposureResult && (options.verbose || overexposurePattern.certainty !== 'LOW')) { + results.structureIssues.push({ + ...overexposureResult, + file: pluginJsonFile, + certainty: overexposurePattern.certainty, + patternId: overexposurePattern.id + }); + } + + // Analyze commands + if (pluginJson.commands) { + for (let idx = 0; idx < pluginJson.commands.length; idx++) { + const cmd = pluginJson.commands[idx]; + const cmdIssues = analyzeCommand(cmd, pluginJsonFile, idx); + results.toolIssues.push(...cmdIssues); + } + } + } + + // Analyze agent files + const agentsDir = path.join(pluginPath, 'agents'); + if (fs.existsSync(agentsDir)) { + const agentFiles = fs.readdirSync(agentsDir).filter(f => f.endsWith('.md')); + + for (const agentFile of agentFiles) { + const agentPath = path.join(agentsDir, agentFile); + const content = fs.readFileSync(agentPath, 'utf8'); + results.filesScanned++; + + // Security checks + const secIssues = securityPatterns.checkSecurity(content, agentPath); + results.securityIssues.push(...secIssues.map(i => ({ + ...i, + file: agentPath + }))); + } + } + + // Analyze command files + const commandsDir = path.join(pluginPath, 'commands'); + if (fs.existsSync(commandsDir)) { + const commandFiles = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')); + + for (const cmdFile of commandFiles) { + const cmdPath = path.join(commandsDir, cmdFile); + const content = fs.readFileSync(cmdPath, 'utf8'); + results.filesScanned++; + + // Security checks + const secIssues = securityPatterns.checkSecurity(content, cmdPath); + results.securityIssues.push(...secIssues.map(i => ({ + ...i, + file: cmdPath + }))); + } + } + + return results; +} + +/** + * Analyze a command definition + * @private + * @param {Object} cmd - Command definition + * @param {string} filePath - Path to plugin.json + * @param {number} cmdIndex - Index of command in commands array + */ +function analyzeCommand(cmd, filePath, cmdIndex) { + const issues = []; + + // Check description + const descPattern = pluginPatterns.pluginPatterns.missing_tool_description; + const descResult = descPattern.check(cmd); + if (descResult) { + issues.push({ + ...descResult, + tool: cmd.name, + file: filePath, + certainty: descPattern.certainty, + patternId: descPattern.id + }); + } + + // Check parameters schema + if (cmd.parameters) { + // Missing additionalProperties + const addPropsPattern = pluginPatterns.pluginPatterns.missing_additional_properties; + const addPropsResult = addPropsPattern.check(cmd.parameters); + if (addPropsResult) { + issues.push({ + ...addPropsResult, + tool: cmd.name, + file: filePath, + filePath: filePath, + schemaPath: `commands[${cmdIndex}].parameters`, + certainty: addPropsPattern.certainty, + patternId: addPropsPattern.id, + autoFixFn: fixer.fixAdditionalProperties + }); + } + + // Missing required + const reqPattern = pluginPatterns.pluginPatterns.missing_required_fields; + const reqResult = reqPattern.check(cmd.parameters); + if (reqResult) { + issues.push({ + ...reqResult, + tool: cmd.name, + file: filePath, + filePath: filePath, + schemaPath: `commands[${cmdIndex}].parameters`, + certainty: reqPattern.certainty, + patternId: reqPattern.id, + autoFixFn: fixer.fixRequiredFields + }); + } + + // Deep nesting + const nestPattern = pluginPatterns.pluginPatterns.deep_nesting; + const nestResult = nestPattern.check(cmd.parameters); + if (nestResult) { + issues.push({ + ...nestResult, + tool: cmd.name, + file: filePath, + certainty: nestPattern.certainty, + patternId: nestPattern.id + }); + } + + // Run tool pattern checks + const toolIssues = toolPatterns.analyzeTool({ + name: cmd.name, + description: cmd.description, + inputSchema: cmd.parameters + }); + issues.push(...toolIssues.map(i => ({ + ...i, + file: filePath + }))); + } + + return issues; +} + +/** + * Analyze all plugins in a directory + * @param {string} pluginsDir - Path to plugins directory + * @param {Object} options - Analysis options + * @returns {Array} Array of analysis results + */ +async function analyzeAllPlugins(pluginsDir, options = {}) { + const results = []; + + if (!fs.existsSync(pluginsDir)) { + return results; + } + + const pluginDirs = fs.readdirSync(pluginsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name); + + for (const pluginName of pluginDirs) { + const pluginPath = path.join(pluginsDir, pluginName); + const result = await analyzePlugin(pluginPath, options); + results.push(result); + } + + return results; +} + +/** + * Main analyze function + * @param {Object} options - Analysis options + * @param {string} options.plugin - Specific plugin name (optional) + * @param {string} options.pluginsDir - Path to plugins directory + * @param {boolean} options.verbose - Include LOW certainty issues + * @returns {Object} Analysis results + */ +async function analyze(options = {}) { + const { + plugin, + pluginsDir = 'plugins', + verbose = false + } = options; + + if (plugin) { + // Analyze single plugin + const pluginPath = path.join(pluginsDir, plugin); + return analyzePlugin(pluginPath, { verbose }); + } else { + // Analyze all plugins + return analyzeAllPlugins(pluginsDir, { verbose }); + } +} + +/** + * Apply fixes to analysis results + * @param {Object|Array} results - Analysis results + * @param {Object} options - Fix options + * @returns {Object} Fix results + */ +async function applyFixes(results, options = {}) { + // Collect all issues + let allIssues = []; + + if (Array.isArray(results)) { + for (const r of results) { + allIssues.push(...(r.toolIssues || [])); + allIssues.push(...(r.structureIssues || [])); + allIssues.push(...(r.securityIssues || [])); + } + } else { + allIssues.push(...(results.toolIssues || [])); + allIssues.push(...(results.structureIssues || [])); + allIssues.push(...(results.securityIssues || [])); + } + + return fixer.applyFixes(allIssues, options); +} + +/** + * Generate report from analysis results + * @param {Object|Array} results - Analysis results + * @param {Object} options - Report options + * @returns {string} Markdown report + */ +function generateReport(results, options = {}) { + if (Array.isArray(results)) { + return reporter.generateSummaryReport(results, options); + } else { + return reporter.generateReport(results, options); + } +} + +module.exports = { + analyze, + analyzePlugin, + analyzeAllPlugins, + applyFixes, + generateReport, + // Re-export sub-modules + pluginPatterns: pluginPatterns.pluginPatterns, + toolPatterns: toolPatterns.toolPatterns, + securityPatterns: securityPatterns.securityPatterns, + reporter, + fixer +}; diff --git a/plugins/perf/lib/enhance/plugin-patterns.js b/plugins/perf/lib/enhance/plugin-patterns.js new file mode 100644 index 00000000..22a87b85 --- /dev/null +++ b/plugins/perf/lib/enhance/plugin-patterns.js @@ -0,0 +1,326 @@ +/** + * Plugin Structure Patterns + * Detection patterns for plugin.json and structure issues + * + * @author Avi Fenesh + * @license MIT + */ + +/** + * Plugin structure patterns with certainty levels + * Following the slop-patterns model + */ +const pluginPatterns = { + /** + * Missing additionalProperties in schema + * HIGH certainty - always fixable + */ + missing_additional_properties: { + id: 'missing_additional_properties', + category: 'tool', + certainty: 'HIGH', + autoFix: true, + description: 'Schema missing additionalProperties: false', + check: (schema) => { + if (!schema || typeof schema !== 'object') return null; + if (schema.type === 'object' && schema.properties) { + if (schema.additionalProperties !== false) { + return { + issue: 'Missing additionalProperties: false', + fix: 'Add "additionalProperties": false to schema', + autoFixFn: (s) => ({ ...s, additionalProperties: false }) + }; + } + } + return null; + } + }, + + /** + * Missing required array in schema + * HIGH certainty - fixable by adding all properties + */ + missing_required_fields: { + id: 'missing_required_fields', + category: 'tool', + certainty: 'HIGH', + autoFix: true, + description: 'Schema missing required field declarations', + check: (schema) => { + if (!schema || typeof schema !== 'object') return null; + if (schema.type === 'object' && schema.properties) { + const propKeys = Object.keys(schema.properties); + if (propKeys.length > 0 && (!schema.required || schema.required.length === 0)) { + return { + issue: 'No required fields declared', + fix: 'Add required array with all mandatory fields' + // autoFixFn is provided by plugin-analyzer which uses fixer.fixRequiredFields + }; + } + } + return null; + } + }, + + /** + * Version mismatch between plugin.json and package.json + * HIGH certainty - fixable by syncing + */ + version_mismatch: { + id: 'version_mismatch', + category: 'structure', + certainty: 'HIGH', + autoFix: true, + description: 'Version mismatch between plugin.json and package.json', + check: (pluginJson, packageJson) => { + if (!pluginJson || !packageJson) return null; + if (pluginJson.version !== packageJson.version) { + return { + issue: `Version mismatch: plugin.json (${pluginJson.version}) vs package.json (${packageJson.version})`, + fix: 'Sync versions', + autoFixFn: (pj) => ({ ...pj, version: packageJson.version }) + }; + } + return null; + } + }, + + /** + * Missing tool description + * HIGH certainty - must have description + */ + missing_tool_description: { + id: 'missing_tool_description', + category: 'tool', + certainty: 'HIGH', + autoFix: false, + description: 'Tool definition missing description', + check: (tool) => { + if (!tool || typeof tool !== 'object') return null; + if (!tool.description || tool.description.trim() === '') { + return { + issue: 'Missing tool description', + fix: 'Add descriptive description field' + }; + } + return null; + } + }, + + /** + * Deeply nested parameter structure + * MEDIUM certainty - may be intentional + */ + deep_nesting: { + id: 'deep_nesting', + category: 'tool', + certainty: 'MEDIUM', + autoFix: false, + description: 'Parameter schema too deeply nested (>2 levels)', + maxDepth: 2, + check: (schema, depth = 0) => { + if (!schema || typeof schema !== 'object') return null; + if (depth > 2) { + return { + issue: `Schema nested ${depth} levels deep (max: 2)`, + fix: 'Flatten parameter structure' + }; + } + // Check nested properties + if (schema.properties) { + for (const prop of Object.values(schema.properties)) { + const nested = pluginPatterns.deep_nesting.check(prop, depth + 1); + if (nested) return nested; + } + } + return null; + } + }, + + /** + * Tool description too long + * MEDIUM certainty - affects token efficiency + */ + long_description: { + id: 'long_description', + category: 'tool', + certainty: 'MEDIUM', + autoFix: false, + description: 'Tool description exceeds 500 characters', + maxLength: 500, + check: (tool) => { + if (!tool || typeof tool !== 'object') return null; + if (tool.description && tool.description.length > 500) { + return { + issue: `Description too long (${tool.description.length} chars, max: 500)`, + fix: 'Shorten description for token efficiency' + }; + } + return null; + } + }, + + /** + * Missing parameter descriptions + * MEDIUM certainty - improves clarity + */ + missing_param_description: { + id: 'missing_param_description', + category: 'tool', + certainty: 'MEDIUM', + autoFix: false, + description: 'Parameter missing description', + check: (schema) => { + if (!schema || !schema.properties) return null; + const missing = []; + for (const [name, prop] of Object.entries(schema.properties)) { + if (!prop.description || prop.description.trim() === '') { + missing.push(name); + } + } + if (missing.length > 0) { + return { + issue: `Parameters missing descriptions: ${missing.join(', ')}`, + fix: 'Add descriptions to all parameters' + }; + } + return null; + } + }, + + /** + * Too many tools in plugin + * LOW certainty - advisory + */ + tool_overexposure: { + id: 'tool_overexposure', + category: 'structure', + certainty: 'LOW', + autoFix: false, + description: 'Plugin exposes many tools (consider splitting)', + maxTools: 10, + check: (pluginJson) => { + if (!pluginJson) return null; + const toolCount = (pluginJson.commands?.length || 0) + (pluginJson.agents?.length || 0); + if (toolCount > 10) { + return { + issue: `Plugin has ${toolCount} tools/commands (consider splitting)`, + fix: 'Consider splitting into multiple focused plugins' + }; + } + return null; + } + }, + + /** + * Missing required plugin.json fields + * HIGH certainty + */ + missing_required_plugin_fields: { + id: 'missing_required_plugin_fields', + category: 'structure', + certainty: 'HIGH', + autoFix: false, + description: 'Plugin.json missing required fields', + requiredFields: ['name', 'version', 'description'], + check: (pluginJson) => { + if (!pluginJson) return null; + const missing = []; + for (const field of pluginPatterns.missing_required_plugin_fields.requiredFields) { + if (!pluginJson[field]) { + missing.push(field); + } + } + if (missing.length > 0) { + return { + issue: `Missing required fields: ${missing.join(', ')}`, + fix: 'Add required fields to plugin.json' + }; + } + return null; + } + }, + + /** + * Invalid version format + * HIGH certainty + */ + invalid_version_format: { + id: 'invalid_version_format', + category: 'structure', + certainty: 'HIGH', + autoFix: false, + description: 'Version does not follow semver format', + check: (pluginJson) => { + if (!pluginJson || !pluginJson.version) return null; + const semverRegex = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?$/; + if (!semverRegex.test(pluginJson.version)) { + return { + issue: `Invalid version format: ${pluginJson.version}`, + fix: 'Use semver format (e.g., 1.0.0)' + }; + } + return null; + } + } +}; + +/** + * Get all patterns + * @returns {Object} All plugin patterns + */ +function getAllPatterns() { + return pluginPatterns; +} + +/** + * Get patterns by certainty level + * @param {string} certainty - HIGH, MEDIUM, or LOW + * @returns {Object} Filtered patterns + */ +function getPatternsByCertainty(certainty) { + const result = {}; + for (const [name, pattern] of Object.entries(pluginPatterns)) { + if (pattern.certainty === certainty) { + result[name] = pattern; + } + } + return result; +} + +/** + * Get patterns by category + * @param {string} category - tool, structure, security + * @returns {Object} Filtered patterns + */ +function getPatternsByCategory(category) { + const result = {}; + for (const [name, pattern] of Object.entries(pluginPatterns)) { + if (pattern.category === category) { + result[name] = pattern; + } + } + return result; +} + +/** + * Get auto-fixable patterns + * @returns {Object} Patterns with autoFix: true + */ +function getAutoFixablePatterns() { + const result = {}; + for (const [name, pattern] of Object.entries(pluginPatterns)) { + if (pattern.autoFix) { + result[name] = pattern; + } + } + return result; +} + +module.exports = { + pluginPatterns, + getAllPatterns, + getPatternsByCertainty, + getPatternsByCategory, + getAutoFixablePatterns +}; diff --git a/plugins/perf/lib/enhance/projectmemory-analyzer.js b/plugins/perf/lib/enhance/projectmemory-analyzer.js new file mode 100644 index 00000000..e0668bbe --- /dev/null +++ b/plugins/perf/lib/enhance/projectmemory-analyzer.js @@ -0,0 +1,541 @@ +/** + * Project Memory Analyzer + * Analyzes CLAUDE.md/AGENTS.md project memory files for optimization opportunities + */ + +const fs = require('fs'); +const path = require('path'); +const { projectMemoryPatterns } = require('./projectmemory-patterns'); + +const PROJECT_MEMORY_FILES = [ + 'CLAUDE.md', + 'AGENTS.md', + '.github/CLAUDE.md', + '.github/AGENTS.md' +]; + +/** + * Find the project memory file in a directory + * @param {string} projectPath - Project root directory + * @returns {Object|null} { path, name, type } or null if not found + */ +function findProjectMemoryFile(projectPath) { + for (const fileName of PROJECT_MEMORY_FILES) { + const filePath = path.join(projectPath, fileName); + if (fs.existsSync(filePath)) { + return { + path: filePath, + name: fileName, + type: fileName.includes('AGENTS') ? 'agents' : 'claude' + }; + } + } + return null; +} + +/** + * Extract file references from markdown content + * @param {string} content - Markdown content + * @returns {Array} Array of file paths referenced + */ +function extractFileReferences(content) { + if (!content || typeof content !== 'string') return []; + + const references = []; + + // Match markdown links: [text](path) + const linkMatches = content.match(/\[([^\]]+)\]\(([^)]+)\)/g) || []; + for (const match of linkMatches) { + const pathMatch = match.match(/\]\(([^)]+)\)/); + if (pathMatch && pathMatch[1]) { + const href = pathMatch[1]; + // Skip URLs and anchors + if (!href.startsWith('http') && !href.startsWith('#') && !href.startsWith('mailto:')) { + references.push(href.split('#')[0]); // Remove anchor + } + } + } + + // Match backtick paths: `path/to/file.ext` or `file.ext` (root files) + const backtickMatches = content.match(/`([^`]+)`/g) || []; + for (const match of backtickMatches) { + const filePath = match.replace(/`/g, ''); + // Include paths with / or extension, exclude spaces and variables + if ((filePath.includes('.') || filePath.includes('/')) && !filePath.includes(' ') && !filePath.startsWith('$')) { + references.push(filePath); + } + } + + return [...new Set(references)]; +} + +/** + * Validate file references exist + * @param {string} content - Markdown content + * @param {string} projectPath - Project root directory + * @returns {Object} { valid: [], broken: [] } + */ +function validateFileReferences(content, projectPath) { + const references = extractFileReferences(content); + const valid = []; + const broken = []; + + const resolvedProjectPath = path.resolve(projectPath); + + for (const ref of references) { + // Skip glob patterns and variable references + if (ref.includes('*') || ref.includes('${')) { + valid.push(ref); + continue; + } + + // Resolve path and validate it stays within project root (prevent path traversal) + const fullPath = path.resolve(projectPath, ref); + if (fullPath.startsWith(resolvedProjectPath) && fs.existsSync(fullPath)) { + valid.push(ref); + } else { + broken.push(ref); + } + } + + return { valid, broken }; +} + +/** + * Extract npm script references from content + * @param {string} content - Markdown content + * @returns {Array} Array of npm commands referenced + */ +function extractCommandReferences(content) { + if (!content || typeof content !== 'string') return []; + + const commands = []; + + // Match npm run commands: npm run