diff --git a/.agents/skills/bmad-code-review/steps/step-02-review.md b/.agents/skills/bmad-code-review/steps/step-02-review.md index e08b7b283..e617db72a 100644 --- a/.agents/skills/bmad-code-review/steps/step-02-review.md +++ b/.agents/skills/bmad-code-review/steps/step-02-review.md @@ -37,6 +37,12 @@ failed_layers: '' # set at runtime: comma-separated list of layers that failed o > - **If the spec file contains a blast radius report AND a Test Coverage Justification BUT no "Mathematical Quality Gate Output"**: this is a Phase 1-level story using only textual justification. Flag as `patch` (not `decision_needed`): "Phase 1 story — consider upgrading to mathematical quality gate via qa-memtrace.mjs." > - **If the spec file contains only a blast radius report (no test justification, no mathematical gate)**: raise a `decision_needed` finding: "Missing both Test Coverage Justification and Mathematical Quality Gate Output — Phase 2 story must include qa-memtrace.mjs execution results." > + > **Quality Gate — Adapter Usage Verification:** Check whether the spec/story file records indicate use of the `memtrace-adapter.mjs` wrapper (rather than raw MCP tool calls) for blast radius and availability queries. Apply these rules: + > - **If the spec/story file's Dev Agent Record or diff commentary references `memtrace-adapter.mjs`** for blast radius queries (`--query get_impact`) and availability checks (`--query list_repos`) → pass: adapter usage confirmed. + > - **If the spec/story file shows `memtrace_get_impact` or `list_indexed_repositories` being called directly** (without the adapter wrapper) for the blast radius step → raise a `patch` finding: "Direct MCP call detected — blast radius step should use `memtrace-adapter.mjs` instead of raw `memtrace_get_impact` or `list_indexed_repositories` for consistent timeout handling and error token emission." + > - **If the blast radius step is absent or the story doesn't involve code modification (new-file-only stories)** → skip this gate. + > - **If the spec/story file involves dead-code queries (`find_dead_code`)**: check whether the adapter was used (`--query find_dead_code`) rather than raw `memtrace_find_dead_code` MCP calls. If `memtrace_find_dead_code` was called directly without the adapter → raise a `patch` finding: "Direct MCP call detected — dead-code query should use `memtrace-adapter.mjs --query find_dead_code` instead of raw `memtrace_find_dead_code` for consistent timeout handling and error token emission." + > > **Quality Gate — Dead Code Pitfall Validation:** Check whether the spec/story file includes a "Dead Code Pitfall Validation Report" section (JSON output from `validate-dead-code.mjs`). Apply these rules: > - **If the spec file contains "Dead Code Pitfall Validation Report"**: verify that the `suspects` list entries were addressed in the implementation (check if corresponding tasks exist in Tasks/Subtasks, or if deleted files match suspect entries). If SUSPECT entries were not addressed → raise a `decision_needed` finding per unaddressed suspect: "SUSPECT dead-code entry not addressed: [name] in [file] — pitfall validation flagged this as truly dead code but no removal task was completed." > - **If the story involves dead-code removal (find_dead_code, dead-code in tasks) BUT no "Dead Code Pitfall Validation Report" exists in the spec file** → raise a `patch` finding: "Missing Dead Code Pitfall Validation Report — story involved dead-code removal but no pitfall validation was performed via validate-dead-code.mjs." diff --git a/.agents/skills/bmad-dev-story/SKILL.md b/.agents/skills/bmad-dev-story/SKILL.md index a6a2026e8..97682972b 100644 --- a/.agents/skills/bmad-dev-story/SKILL.md +++ b/.agents/skills/bmad-dev-story/SKILL.md @@ -301,15 +301,15 @@ Activation is complete. Begin the workflow below. Extract target symbols and files from the story's Dev Notes and Tasks/Subtasks sections. Identify every file that this story will modify or create. ASK user: "Which symbols or files are you modifying? The story doesn't specify explicit targets for blast radius analysis." - - Verify Memtrace MCP tools are available. Call `list_indexed_repositories` to check connectivity and confirm the index reflects the current codebase state. - + + Verify Memtrace MCP tools are available by calling the adapter: `node _bmad/scripts/memtrace/memtrace-adapter.mjs --query list_repos`. If exit 0 — Memtrace is reachable; parse the STDOUT JSON for repository list and index freshness timestamps. If exit 1 — Memtrace is unavailable. + HALT: "Memtrace MCP server is not available. Structural blast radius verification cannot be performed. Please start the Memtrace server or explicitly override this safety check." - - For each target symbol, call `memtrace_get_impact` with the symbol as the target. Process targets SEQUENTIALLY using `for...of` with `await` — NEVER use `Promise.all`. - Collate the results: extract `risk_level`, `affected_symbols`, `affected_files` from each response. + + For each target symbol, call the memtrace-adapter: `node _bmad/scripts/memtrace/memtrace-adapter.mjs --target --query get_impact`. Process targets SEQUENTIALLY using `for...of` with `await` — NEVER use `Promise.all`. If the adapter exits with code 1 → HALT with timeout/unavailable message. + Parse the adapter's STDOUT JSON for each target. Extract `risk_level`, `affected_symbols` (array of {name, file, depth}), and `affected_files` from the JSON output. Apply hierarchical summarization to keep the final report under 2000 tokens: @@ -376,7 +376,7 @@ Activation is complete. Begin the workflow below. - `module-C`: Partial coverage — `test/module-c.test.ts` covers 3 of 5 impacted functions Enforce the combined token budget: the blast radius report + test coverage justification together must not exceed 2000 tokens. If the combined output exceeds this limit, prioritize: (1) uncovered modules, (2) high-risk modules, (3) covered modules. Keep the table concise (one line per module). - If the blast radius report has zero affected modules (empty result from `memtrace_get_impact`), skip the Test Coverage Justification and append a note: "No affected modules to map — blast radius is empty." + If the blast radius report has zero affected modules (empty result from `memtrace-adapter.mjs --query get_impact`), skip the Test Coverage Justification and append a note: "No affected modules to map — blast radius is empty." Ask the user for their coverage threshold: "Review threshold: block if N% of modules are uncovered? (0 = never block, 100 = block if any uncovered)": Store the threshold. During the code-review phase, the Acceptance Auditor will block changes exceeding this threshold. @@ -426,7 +426,11 @@ Activation is complete. Begin the workflow below. If the story involves dead-code removal, validate candidates against pitfalls-catalog.json before proceeding. - Call `memtrace_find_dead_code` via MCP to retrieve dead-code candidates for the target module(s). Process sequentially using `for...of` with `await`. + For each target module, call the memtrace-adapter: `node _bmad/scripts/memtrace/memtrace-adapter.mjs --target --query find_dead_code [--repo ]`. Process sequentially using `for...of` with `await`. Parse the adapter's STDOUT JSON for the `symbols` array. + + ✅ No dead-code candidates found in target module. Pitfall validation skipped. + + Serialize the dead-code candidates to a temporary JSON file in the system temp directory. Execute: `node _bmad/scripts/memtrace/validate-dead-code.mjs --candidates ` Read the script's STDOUT (JSON output) and capture the exit code. @@ -477,6 +481,8 @@ Activation is complete. Begin the workflow below. Clean up temporary JSON files. + + Skip this substep entirely. diff --git a/.agents/skills/bmad-quick-dev/step-03-implement.md b/.agents/skills/bmad-quick-dev/step-03-implement.md index bd86e3205..5c37b0ddb 100644 --- a/.agents/skills/bmad-quick-dev/step-03-implement.md +++ b/.agents/skills/bmad-quick-dev/step-03-implement.md @@ -22,9 +22,9 @@ Verify `{spec_file}` resolves to a non-empty path and the file exists on disk. I 1. **Identify targets**: Extract the symbols and files being modified from `{spec_file}` — the `## Code Map` section lists target files and their roles. -2. **Verify Memtrace availability**: Check if Memtrace MCP tools are reachable (call `list_indexed_repositories` or equivalent). If unavailable, HALT: "Memtrace MCP server is not available. Structural blast radius verification cannot be performed. Please start the Memtrace server or explicitly override this safety check." +2. **Verify Memtrace availability**: Check if the Memtrace MCP server is reachable by calling the adapter: `node _bmad/scripts/memtrace/memtrace-adapter.mjs --query list_repos`. If exit 0 — server is reachable (parse STDOUT JSON for repository list). If exit 1 — HALT: "Memtrace MCP server is not available. Structural blast radius verification cannot be performed. Please start the Memtrace server or explicitly override this safety check." -3. **Calculate blast radius**: For each target symbol, call `memtrace_get_impact`. Process targets SEQUENTIALLY using `for...of` — NEVER use `Promise.all`. Extract `risk_level`, `affected_symbols`, and `affected_files`. +3. **Calculate blast radius**: For each target symbol, call the memtrace-adapter: `node _bmad/scripts/memtrace/memtrace-adapter.mjs --target --query get_impact`. Process targets SEQUENTIALLY using `for...of` — NEVER use `Promise.all`. If adapter exits with code 1 → HALT (timeout or unavailable). Parse the STDOUT JSON extracting `risk_level`, `affected_symbols`, and `affected_files`. 4. **Summarize for token budget**: Keep the final report under 2000 tokens: - Collapse depth > 3 into module-level counts only @@ -91,7 +91,7 @@ Verify `{spec_file}` resolves to a non-empty path and the file exists on disk. I - The qa-memtrace.mjs exit code is the FINAL authority. Exit 1 is a HARD BLOCK on implementation. 5c. **Dead Code Pitfall Validation**: If the story involves dead-code removal (find_dead_code usage in context or tasks): - - Call `memtrace_find_dead_code` via MCP for the target module(s). Process sequentially — NEVER use `Promise.all`. + - Call the memtrace-adapter: `node _bmad/scripts/memtrace/memtrace-adapter.mjs --target --query find_dead_code [--repo ]`. Process sequentially — NEVER use `Promise.all`. Parse the adapter's STDOUT JSON for the `symbols` array. - Serialize candidates to a temp JSON file. - Run: `node _bmad/scripts/memtrace/validate-dead-code.mjs --candidates ` - If exit 0: log output to `{spec_file}` completion notes as "Dead Code Pitfall Validation Report". Present SUSPECT entries for manual review. FALSE_POS and GHOST are ignored. diff --git a/.agents/skills/bmad-quick-dev/step-oneshot.md b/.agents/skills/bmad-quick-dev/step-oneshot.md index 7b0de0030..7d6ba0c49 100644 --- a/.agents/skills/bmad-quick-dev/step-oneshot.md +++ b/.agents/skills/bmad-quick-dev/step-oneshot.md @@ -17,9 +17,9 @@ deferred_work_file: '{implementation_artifacts}/deferred-work.md' 1. **Identify targets**: Derive the symbols/files being modified from the clarified user intent. If ambiguous, ask the user for explicit targets. -2. **Verify Memtrace availability**: Check if Memtrace MCP tools are reachable (call `list_indexed_repositories`). If unavailable, HALT: "Memtrace MCP server is not available. Structural blast radius verification cannot be performed. Please start the Memtrace server or explicitly override this safety check." +2. **Verify Memtrace availability**: Check if the Memtrace MCP server is reachable by calling the adapter: `node _bmad/scripts/memtrace/memtrace-adapter.mjs --query list_repos`. If exit 0 — server is reachable (parse STDOUT JSON for repository list). If exit 1 — HALT: "Memtrace MCP server is not available. Structural blast radius verification cannot be performed. Please start the Memtrace server or explicitly override this safety check." -3. **Calculate blast radius**: For each target symbol, call `memtrace_get_impact`. Process targets SEQUENTIALLY — NEVER use `Promise.all`. Extract `risk_level`, `affected_symbols`, and `affected_files`. +3. **Calculate blast radius**: For each target symbol, call the memtrace-adapter: `node _bmad/scripts/memtrace/memtrace-adapter.mjs --target --query get_impact`. Process targets SEQUENTIALLY — NEVER use `Promise.all`. If adapter exits with code 1 → HALT (timeout or unavailable). Parse the STDOUT JSON extracting `risk_level`, `affected_symbols`, and `affected_files`. 4. **Summarize for token budget**: Keep report under 2000 tokens — collapse depth > 3, deduplicate, report top 20 symbols by risk, use concise bullets. @@ -75,7 +75,7 @@ deferred_work_file: '{implementation_artifacts}/deferred-work.md' - The qa-memtrace.mjs exit code is the FINAL authority. Exit 1 is a HARD BLOCK on implementation. 5c. **Dead Code Pitfall Validation**: If the story involves dead-code removal (find_dead_code usage): - - Call `memtrace_find_dead_code` via MCP. Process sequentially — NEVER use `Promise.all`. + - Call the memtrace-adapter: `node _bmad/scripts/memtrace/memtrace-adapter.mjs --target --query find_dead_code [--repo ]`. Process sequentially — NEVER use `Promise.all`. Parse the adapter's STDOUT JSON for the `symbols` array. - Serialize candidates to a temp JSON file. - Run: `node _bmad/scripts/memtrace/validate-dead-code.mjs --candidates ` - If exit 0: log output to `{spec_file}` completion notes as "Dead Code Pitfall Validation Report". Present SUSPECT entries for manual review. Ignore FALSE_POS and GHOST. diff --git a/.agents/skills/gds-code-review/steps/step-02-review.md b/.agents/skills/gds-code-review/steps/step-02-review.md index 78bddf240..efc73b9e7 100644 --- a/.agents/skills/gds-code-review/steps/step-02-review.md +++ b/.agents/skills/gds-code-review/steps/step-02-review.md @@ -37,6 +37,12 @@ failed_layers: '' # set at runtime: comma-separated list of layers that failed o > - **If the spec file contains a blast radius report AND a Test Coverage Justification BUT no "Mathematical Quality Gate Output"**: this is a Phase 1-level story using only textual justification. Flag as `patch` (not `decision_needed`): "Phase 1 story — consider upgrading to mathematical quality gate via qa-memtrace.mjs." > - **If the spec file contains only a blast radius report (no test justification, no mathematical gate)**: raise a `decision_needed` finding: "Missing both Test Coverage Justification and Mathematical Quality Gate Output — Phase 2 story must include qa-memtrace.mjs execution results." > + > **Quality Gate — Adapter Usage Verification:** Check whether the spec/story file records indicate use of the `memtrace-adapter.mjs` wrapper (rather than raw MCP tool calls) for blast radius and availability queries. Apply these rules: + > - **If the spec/story file's Dev Agent Record or diff commentary references `memtrace-adapter.mjs`** for blast radius queries (`--query get_impact`) and availability checks (`--query list_repos`) → pass: adapter usage confirmed. + > - **If the spec/story file shows `memtrace_get_impact` or `list_indexed_repositories` being called directly** (without the adapter wrapper) for the blast radius step → raise a `patch` finding: "Direct MCP call detected — blast radius step should use `memtrace-adapter.mjs` instead of raw `memtrace_get_impact` or `list_indexed_repositories` for consistent timeout handling and error token emission." + > - **If the blast radius step is absent or the story doesn't involve code modification (new-file-only stories)** → skip this gate. + > - **If the spec/story file involves dead-code queries (`find_dead_code`)**: check whether the adapter was used (`--query find_dead_code`) rather than raw `memtrace_find_dead_code` MCP calls. If `memtrace_find_dead_code` was called directly without the adapter → raise a `patch` finding: "Direct MCP call detected — dead-code query should use `memtrace-adapter.mjs --query find_dead_code` instead of raw `memtrace_find_dead_code` for consistent timeout handling and error token emission." + > > **Quality Gate — Dead Code Pitfall Validation:** Check whether the spec/story file includes a "Dead Code Pitfall Validation Report" section (JSON output from `validate-dead-code.mjs`). Apply these rules: > - **If the spec file contains "Dead Code Pitfall Validation Report"**: verify that the `suspects` list entries were addressed in the implementation (check if corresponding tasks exist in Tasks/Subtasks, or if deleted files match suspect entries). If SUSPECT entries were not addressed → raise a `decision_needed` finding per unaddressed suspect: "SUSPECT dead-code entry not addressed: [name] in [file] — pitfall validation flagged this as truly dead code but no removal task was completed." > - **If the story involves dead-code removal (find_dead_code, dead-code in tasks) BUT no "Dead Code Pitfall Validation Report" exists in the spec file** → raise a `patch` finding: "Missing Dead Code Pitfall Validation Report — story involved dead-code removal but no pitfall validation was performed via validate-dead-code.mjs." diff --git a/_bmad/scripts/memtrace/memtrace-adapter.mjs b/_bmad/scripts/memtrace/memtrace-adapter.mjs new file mode 100644 index 000000000..2abcb1e6d --- /dev/null +++ b/_bmad/scripts/memtrace/memtrace-adapter.mjs @@ -0,0 +1,328 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const TIMEOUT_MS = parseInt(process.env.MEMTRACE_TIMEOUT_MS || '10000', 10); +const TIMEOUT_TOKEN = 'MEMTRACE_MCP_ERROR_TIMEOUT'; + +function parseArgs() { + const args = process.argv.slice(2); + + if (args.length === 0 || args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node memtrace-adapter.mjs --target --query [--repo ] + +Arguments: + --target Symbol name or file path to query (required for get_impact, find_dead_code) + --query Query type: get_impact, find_dead_code, list_repos (required) + --repo Repository ID (optional — auto-detected from .memtrace-workspace if omitted) + +Query types: + get_impact Fetch structural blast radius for a target symbol + find_dead_code Find dead code in a target module (stub — full impl in Story 3.2) + list_repos List indexed repositories with freshness timestamps + +Examples: + node memtrace-adapter.mjs --target "validateToken" --query get_impact + node memtrace-adapter.mjs --query list_repos + node memtrace-adapter.mjs --target "src/auth" --query find_dead_code + node memtrace-adapter.mjs --help`); + process.exit(0); + } + + const result = { target: null, query: null, repo: null }; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--target' && i + 1 < args.length) { + result.target = args[++i]; + } else if (args[i] === '--query' && i + 1 < args.length) { + result.query = args[++i]; + } else if (args[i] === '--repo' && i + 1 < args.length) { + result.repo = args[++i]; + } else { + fail(`Unknown argument: ${args[i]}`); + process.exit(1); + } + } + + if (!result.query) { + fail('Missing required argument: --query'); + process.exit(1); + } + + const validQueries = ['get_impact', 'find_dead_code', 'list_repos']; + if (!validQueries.includes(result.query)) { + fail(`Invalid query type: "${result.query}". Valid: ${validQueries.join(', ')}`); + process.exit(1); + } + + if ((result.query === 'get_impact' || result.query === 'find_dead_code')) { + if (result.target === null) { + fail(`Missing required argument: --target is required for --query ${result.query}`); + process.exit(1); + } + if (result.target.trim() === '') { + fail('--target must be a non-empty string'); + process.exit(1); + } + } + + return result; +} + +function fail(msg) { + console.error(`ERROR: ${msg}`); + console.log(TIMEOUT_TOKEN); +} + +class McpClient { + constructor() { + this.child = null; + this.stdoutBuffer = ''; + this.requestId = 0; + } + + spawn() { + return new Promise((resolvePromise, reject) => { + try { + this.child = spawn('memtrace', ['mcp'], { + stdio: ['pipe', 'pipe', 'pipe'], + shell: process.platform === 'win32', + windowsHide: true + }); + } catch (err) { + reject(new Error(`Failed to spawn memtrace: ${err.message}`)); + return; + } + + const onError = (err) => { + cleanup(); + const msg = err.code === 'ENOENT' + ? `memtrace binary not found on PATH. Ensure memtrace is installed (npm install -g memtrace) and available.` + : `memtrace spawn error: ${err.message}`; + reject(new Error(msg)); + }; + + const onExit = (code, signal) => { + cleanup(); + if (signal) { + reject(new Error(`memtrace process terminated by signal ${signal}`)); + } else if (code !== 0) { + reject(new Error(`memtrace process exited with code ${code}`)); + } + }; + + const cleanup = () => { + if (this.child) { + this.child.removeListener('error', onError); + this.child.removeListener('exit', onExit); + this.child.stderr.removeListener('data', onStderr); + } + }; + + const onStderr = () => { + // MCP servers may log diagnostics to stderr — capture but don't reject + }; + + this.child.on('error', onError); + this.child.on('exit', onExit); + this.child.stderr.on('data', onStderr); + + resolvePromise(); + }); + } + + sendRequest(method, params = {}) { + const id = ++this.requestId; + const request = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'; + return new Promise((resolvePromise, reject) => { + const listener = (data) => { + this.stdoutBuffer += data.toString(); + const lines = this.stdoutBuffer.split('\n'); + this.stdoutBuffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const response = JSON.parse(line); + if (response.id === id) { + this.child.stdout.removeListener('data', listener); + if (response.error) { + reject(new Error(`MCP error: ${response.error.message || JSON.stringify(response.error)}`)); + } else { + resolvePromise(response.result); + } + return; + } + } catch (err) { + // Partial JSON in buffer — wait for more data + } + } + }; + + this.child.stdout.on('data', listener); + this.child.stdin.write(request); + }); + } + + async handshake() { + const capabilities = await this.sendRequest('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'bmad-memtrace-adapter', version: '1.0.0' } + }); + // Send initialized notification (fire-and-forget, no response expected) + this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }) + '\n'); + return capabilities; + } + + async callTool(name, args) { + return this.sendRequest('tools/call', { name, arguments: args }); + } + + async shutdown() { + try { + await this.sendRequest('shutdown', {}); + } catch (err) { + // Shutdown errors are non-fatal + } + } + + kill() { + if (this.child) { + try { this.child.stdin.end(); } catch (e) {} + try { this.child.kill('SIGTERM'); } catch (e) {} + // SIGTERM is sufficient on modern systems; no need for SIGKILL timer + } + } +} + +function resolveRepoId(args) { + if (args.repo) return args.repo; + + // Try to auto-detect from .memtrace-workspace + let cwd = process.cwd(); + const parts = cwd.split(/[\\/]/); + for (let i = parts.length; i > 0; i--) { + const dir = parts.slice(0, i).join('/'); + if (existsSync(resolve('/', ...(process.platform === 'win32' ? [dir.split(':')[0] + ':', ...dir.split(':')[1]?.split('/').filter(Boolean) || []] : []), '.memtrace-workspace'))) { + // Found workspace anchor — use basename + return parts[i - 1] || parts[parts.length - 1]; + } + } + + // Fallback: use CWD basename + return parts[parts.length - 1]; +} + +async function queryGetImpact(client, target, repoId) { + const result = await client.callTool('get_impact', { target, repo_id: repoId }); + return { + target, + risk_level: result.risk || 'Low', + affected_symbols: (result.affected_symbols || []).map(s => ({ + name: s.name, + file: s.file || '', + depth: s.depth || 1 + })), + affected_files: result.affected_files || [], + total_count: result.total_affected || result.affected_symbols?.length || 0, + elapsed_ms: 0 + }; +} + +async function queryFindDeadCode(client, target, repoId) { + const result = await client.callTool('find_dead_code', { + repo_id: repoId, + file_path: target + }); + const symbols = (result?.symbols || []).map(s => ({ + name: s.name || '', + kind: s.kind || 'Function', + file: s.file || '', + line: s.line || 0 + })); + return { + query: 'find_dead_code', + target, + symbols, + total_count: symbols.length, + elapsed_ms: 0 + }; +} + +async function queryListRepos(client) { + const result = await client.callTool('list_indexed_repositories', {}); + const repos = Array.isArray(result?.repos) ? result.repos : []; + return { + query: 'list_repos', + repositories: repos.map(r => ({ + repo_id: r.repo_id, + last_indexed: r.last_indexed_at || r.last_indexed || null, + total_nodes: r.total_nodes || r.nodes || 0 + })), + elapsed_ms: 0 + }; +} + +function withTimeout(promise, ms) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new TimeoutError(`Query timed out after ${ms}ms`)); + }, ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +class TimeoutError extends Error { + constructor(message) { + super(message); + this.name = 'TimeoutError'; + } +} + +async function main() { + const args = parseArgs(); + const start = Date.now(); + + const repoId = resolveRepoId(args); + const client = new McpClient(); + + try { + await client.spawn(); + await withTimeout(client.handshake(), TIMEOUT_MS); + + let queryFn; + if (args.query === 'get_impact') { + queryFn = queryGetImpact(client, args.target, repoId); + } else if (args.query === 'find_dead_code') { + queryFn = queryFindDeadCode(client, args.target, repoId); + } else if (args.query === 'list_repos') { + queryFn = queryListRepos(client); + } else { + throw new Error(`Unhandled query type: ${args.query}`); + } + + let result = await withTimeout(queryFn, TIMEOUT_MS); + result.elapsed_ms = Date.now() - start; + + await withTimeout(client.shutdown(), 5000); + + console.log(JSON.stringify(result, null, 2)); + process.exit(0); + } catch (err) { + client.kill(); + const elapsed = Date.now() - start; + + if (err instanceof TimeoutError) { + console.log(TIMEOUT_TOKEN); + console.error(`ERROR: Query timed out after ${elapsed}ms`); + } else { + fail(err.message); + } + process.exit(1); + } +} + +main(); diff --git a/_bmad/scripts/memtrace/memtrace-adapter.test.mjs b/_bmad/scripts/memtrace/memtrace-adapter.test.mjs new file mode 100644 index 000000000..3cd5c4972 --- /dev/null +++ b/_bmad/scripts/memtrace/memtrace-adapter.test.mjs @@ -0,0 +1,173 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { resolve } from 'node:path'; + +const ADAPTER = resolve(import.meta.dirname, 'memtrace-adapter.mjs'); + +function runAdapter(args) { + return new Promise((resolvePromise) => { + execFile(process.execPath, [ADAPTER, ...args], { + env: { ...process.env, NODE_NO_WARNINGS: '1' }, + timeout: 30000, + windowsHide: true + }, (error, stdout, stderr) => { + resolvePromise({ + code: error?.code === 'ETIMEDOUT' ? null : (error?.code || 0), + signal: error?.signal || null, + stdout: stdout || '', + stderr: stderr || '', + error + }); + }); + }); +} + +describe('memtrace-adapter.mjs', () => { + + describe('CLI argument handling', () => { + it('should output usage with --help and exit 0', async () => { + const r = await runAdapter(['--help']); + assert.equal(r.code, 0); + assert.ok(r.stdout.includes('Usage:')); + assert.ok(r.stdout.includes('--query')); + }); + + it('should output usage with -h and exit 0', async () => { + const r = await runAdapter(['-h']); + assert.equal(r.code, 0); + assert.ok(r.stdout.includes('Usage:')); + }); + + it('should output usage with no args and exit 0', async () => { + const r = await runAdapter([]); + assert.equal(r.code, 0); + assert.ok(r.stdout.includes('Usage:')); + }); + + it('should exit 1 when missing --target for get_impact', async () => { + const r = await runAdapter(['--query', 'get_impact']); + assert.equal(r.code, 1); + assert.ok(r.stderr.includes('--target')); + }); + + it('should exit 1 when missing --target for find_dead_code', async () => { + const r = await runAdapter(['--query', 'find_dead_code']); + assert.equal(r.code, 1); + assert.ok(r.stderr.includes('--target')); + }); + + it('should exit 1 when --target is empty string', async () => { + const r = await runAdapter(['--target', '', '--query', 'get_impact']); + assert.equal(r.code, 1); + assert.ok(r.stderr.includes('non-empty')); + }); + + it('should exit 1 for unknown --query type', async () => { + const r = await runAdapter(['--target', 'foo', '--query', 'invalid_query']); + assert.equal(r.code, 1); + assert.ok(r.stderr.includes('Invalid query')); + }); + + it('should exit 1 when missing --query', async () => { + const r = await runAdapter(['--target', 'foo']); + assert.equal(r.code, 1); + assert.ok(r.stderr.includes('--query')); + }); + + it('should exit 1 for unknown argument', async () => { + const r = await runAdapter(['--unknown']); + assert.equal(r.code, 1); + assert.ok(r.stderr.includes('Unknown argument')); + }); + }); + + describe('MCP queries', () => { + + it('should list repositories and return valid JSON with repos array', { timeout: 20000 }, async () => { + const r = await runAdapter(['--query', 'list_repos']); + assert.equal(r.code, 0); + let parsed; + try { + parsed = JSON.parse(r.stdout); + } catch (e) { + assert.fail(`STDOUT is not valid JSON: ${r.stdout.slice(0, 200)}`); + } + assert.equal(parsed.query, 'list_repos'); + assert.ok(Array.isArray(parsed.repositories)); + assert.ok(typeof parsed.elapsed_ms === 'number'); + }); + + it('should query get_impact and return structured JSON on exit 0 (or error with MEMTRACE_MCP_ERROR_TIMEOUT on exit 1)', { timeout: 30000 }, async () => { + const r = await runAdapter(['--target', 'bmad-dev-story', '--query', 'get_impact', '--repo', 'Repos']); + // Should either succeed with data or fail gracefully + if (r.code === 0) { + let parsed; + try { + parsed = JSON.parse(r.stdout); + } catch (e) { + assert.fail(`STDOUT is not valid JSON: ${r.stdout.slice(0, 200)}`); + } + assert.ok(typeof parsed.target === 'string'); + assert.ok(typeof parsed.risk_level === 'string'); + assert.ok(Array.isArray(parsed.affected_symbols)); + assert.ok(typeof parsed.total_count === 'number'); + assert.ok(typeof parsed.elapsed_ms === 'number'); + } else { + // On failure, must emit MEMTRACE_MCP_ERROR_TIMEOUT + assert.ok(r.stdout.includes('MEMTRACE_MCP_ERROR_TIMEOUT'), + `Expected MEMTRACE_MCP_ERROR_TIMEOUT in stdout. Exit code: ${r.code}, stderr: ${r.stderr.slice(0, 200)}`); + } + }); + + it('should query find_dead_code with --target and --repo and return structured JSON', { timeout: 30000 }, async () => { + const r = await runAdapter(['--target', 'src', '--query', 'find_dead_code', '--repo', 'Repos']); + if (r.code === 0) { + let parsed; + try { + parsed = JSON.parse(r.stdout); + } catch (e) { + assert.fail(`STDOUT is not valid JSON: ${r.stdout.slice(0, 200)}`); + } + assert.equal(parsed.query, 'find_dead_code'); + assert.equal(typeof parsed.target, 'string'); + assert.ok(Array.isArray(parsed.symbols)); + assert.equal(parsed.total_count, parsed.symbols.length); + assert.equal(typeof parsed.elapsed_ms, 'number'); + assert.equal(parsed.note, undefined, 'Stub note must be removed'); + if (parsed.symbols.length > 0) { + assert.ok(parsed.symbols.every(s => typeof s.name === 'string' && typeof s.file === 'string'), + 'Each symbol must have name and file fields'); + } + } else { + assert.ok(r.stdout.includes('MEMTRACE_MCP_ERROR_TIMEOUT'), + `Expected MEMTRACE_MCP_ERROR_TIMEOUT. Exit code: ${r.code}`); + } + }); + + it('should query find_dead_code without --repo and auto-detect repo', { timeout: 30000 }, async () => { + const r = await runAdapter(['--target', 'src', '--query', 'find_dead_code']); + if (r.code === 0) { + let parsed = JSON.parse(r.stdout); + assert.equal(parsed.query, 'find_dead_code'); + assert.ok(Array.isArray(parsed.symbols)); + assert.equal(parsed.total_count, parsed.symbols.length); + if (parsed.symbols.length > 0) { + assert.ok(parsed.symbols.every(s => typeof s.name === 'string' && typeof s.file === 'string'), + 'Each symbol must have name and file fields'); + } + } else { + assert.ok(r.stdout.includes('MEMTRACE_MCP_ERROR_TIMEOUT')); + } + }); + + it('should emit MEMTRACE_MCP_ERROR_TIMEOUT and exit 1 on MCP error', { timeout: 20000 }, async () => { + // Query for a non-existent target to trigger an MCP error + const r = await runAdapter(['--target', '!@#$%^&*()_NONEXISTENT_SYMBOL_12345', '--query', 'get_impact', '--repo', 'Repos']); + // Should always exit 0 or 1 — never hang + if (r.code === 1) { + assert.ok(r.stdout.includes('MEMTRACE_MCP_ERROR_TIMEOUT')); + } + }); + }); +});