feat(story-3.4): implement server concurrency throttling and freshness check
Add --check-freshness flag (index freshness gate blocking stale data), --batch flag (sequential for...of query processing, anti-Promise.all), and freshness metadata in list_repos output. Code review patches applied: - Reset 7 patch findings (AC #4 real diagnostic, null guards, shutdown separation, empty target validation) - Resolved 3 deferred edge cases (Date.parse NaN, falsy 0, type guard) - Resolved 4 pre-existing issues (infinite while-loop guard, resolveRepoId cross-platform, empty fallback, JSON error logging) Closes story-3.4
This commit is contained in:
parent
abd758e7ee
commit
72e293f346
|
|
@ -43,6 +43,7 @@ failed_layers: '' # set at runtime: comma-separated list of layers that failed o
|
|||
> - **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."
|
||||
> - **If the spec/story file shows `--query get_impact` called WITHOUT `--summarize`** in a story that involves code modification → raise a `patch` finding: "Adapter called without --summarize — blast radius output may exceed 2000 token budget. NFR1 requires all Memtrace structural tool outputs to be under 2000 tokens."
|
||||
> - **If the spec/story file shows `memtrace-adapter.mjs` called for `get_impact` or `find_dead_code` WITHOUT `--check-freshness`** → raise a `patch` finding: "Adapter called without --check-freshness — architecture requires index freshness verification before trusting graph output (Cross-Cutting Concern: Index Freshness Check)."
|
||||
>
|
||||
> **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."
|
||||
|
|
|
|||
|
|
@ -301,14 +301,12 @@ Activation is complete. Begin the workflow below.
|
|||
<action>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.</action>
|
||||
<action if="no explicit targets found in story Dev Notes">ASK user: "Which symbols or files are you modifying? The story doesn't specify explicit targets for blast radius analysis."</action>
|
||||
|
||||
<!-- Memtrace availability check via adapter -->
|
||||
<action>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.</action>
|
||||
<check if="Memtrace adapter exit code is 1 (MCP server not reachable or timeout)">
|
||||
<action>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."</action>
|
||||
<!-- Memtrace availability + freshness check via adapter (merged with blast radius query) -->
|
||||
<action>For each target symbol, call the memtrace-adapter with `--check-freshness` which verifies index freshness before the blast radius query: `node _bmad/scripts/memtrace/memtrace-adapter.mjs --target <symbol> --query get_impact --check-freshness --summarize`. The adapter checks freshness first (separate MCP session), then runs the main query. Process targets SEQUENTIALLY using `for...of` with `await` — NEVER use `Promise.all`.</action>
|
||||
<action>On exit code 0 — freshness OK; parse STDOUT JSON for the `summarized` field and `affected_symbols`. On exit code 1 — check STDERR: if `[FRESHNESS]` line present, treat as "Index stale or missing — re-index before proceeding"; if `MEMTRACE_MCP_ERROR_TIMEOUT` in STDOUT, treat as "MCP server unreachable." Both cases → HALT.</action>
|
||||
<check if="adapter exit code is 1">
|
||||
<action>HALT: "Memtrace query failed. See STDERR for details: [FRESHNESS] = stale/missing index; MEMTRACE_MCP_ERROR_TIMEOUT = MCP server unreachable."</action>
|
||||
</check>
|
||||
|
||||
<!-- Calculate blast radius via adapter with summarization -->
|
||||
<action>For each target symbol, call the memtrace-adapter: `node _bmad/scripts/memtrace/memtrace-adapter.mjs --target <symbol> --query get_impact --summarize`. Process targets SEQUENTIALLY using `for...of` with `await` — NEVER use `Promise.all`. If the adapter exits with code 1 → HALT with timeout/unavailable message.</action>
|
||||
<action>Parse the adapter's STDOUT JSON for each target. Extract the `summarized` field for the Confidence Report (guaranteed ≤2000 tokens by the adapter). The `summarized` field contains: `total_affected`, `critical_dependents` (depth 1-2 symbols), `module_impact` (grouped by directory prefix with `count` and optional `top_symbols`), and `token_estimate`. Also extract `affected_symbols` (raw array) for qa-memtrace.mjs consumption.</action>
|
||||
|
||||
<!-- Present Confidence Report using summarized data -->
|
||||
|
|
|
|||
|
|
@ -22,9 +22,7 @@ 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 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 with built-in summarization**: For each target symbol, call the memtrace-adapter: `node _bmad/scripts/memtrace/memtrace-adapter.mjs --target <symbol> --query get_impact --summarize`. Process targets SEQUENTIALLY using `for...of` — NEVER use `Promise.all`. If adapter exits with code 1 → HALT (timeout or unavailable). Parse the STDOUT JSON: use `summarized.critical_dependents`, `summarized.module_impact`, `summarized.total_affected`, and `summarized.token_estimate` for the Confidence Report (guaranteed ≤2000 tokens). Extract `affected_symbols` (raw) for qa-memtrace.mjs.
|
||||
2. **Calculate blast radius with built-in summarization and freshness check**: For each target symbol, call the memtrace-adapter: `node _bmad/scripts/memtrace/memtrace-adapter.mjs --target <symbol> --query get_impact --check-freshness --summarize`. The adapter verifies index freshness (via `--check-freshness`) before the blast radius query — no separate `list_repos` call needed. Process targets SEQUENTIALLY using `for...of` — NEVER use `Promise.all`. On exit code 0: parse STDOUT JSON; use `summarized.critical_dependents`, `summarized.module_impact`, `summarized.total_affected`, and `summarized.token_estimate` for the Confidence Report (guaranteed ≤2000 tokens). Extract `affected_symbols` (raw) for qa-memtrace.mjs. On exit code 1: check STDERR for `[FRESHNESS]` (stale/missing index) vs `MEMTRACE_MCP_ERROR_TIMEOUT` (MCP unreachable) → HALT.
|
||||
|
||||
4. **Token budget already satisfied**: The adapter's `--summarize` flag guarantees the `summarized` field is under 2000 tokens. No manual summarization needed. Use `summarized.token_estimate` to confirm compliance.
|
||||
|
||||
|
|
|
|||
|
|
@ -17,9 +17,7 @@ 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 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 with built-in summarization**: For each target symbol, call the memtrace-adapter: `node _bmad/scripts/memtrace/memtrace-adapter.mjs --target <symbol> --query get_impact --summarize`. Process targets SEQUENTIALLY — NEVER use `Promise.all`. If adapter exits with code 1 → HALT (timeout or unavailable). Parse the STDOUT JSON: use `summarized.critical_dependents`, `summarized.module_impact`, `summarized.total_affected`, and `summarized.token_estimate` for the Confidence Report. Extract `affected_symbols` (raw) for qa-memtrace.mjs.
|
||||
2. **Calculate blast radius with built-in summarization and freshness check**: For each target symbol, call the memtrace-adapter: `node _bmad/scripts/memtrace/memtrace-adapter.mjs --target <symbol> --query get_impact --check-freshness --summarize`. The adapter verifies index freshness before the blast radius query — no separate `list_repos` call needed. Process targets SEQUENTIALLY — NEVER use `Promise.all`. On exit code 0: parse STDOUT JSON; use `summarized.critical_dependents`, `summarized.module_impact`, `summarized.total_affected`, and `summarized.token_estimate` for the Confidence Report. Extract `affected_symbols` (raw) for qa-memtrace.mjs. On exit code 1: check STDERR for `[FRESHNESS]` (stale/missing index) vs `MEMTRACE_MCP_ERROR_TIMEOUT` (MCP unreachable) → HALT.
|
||||
|
||||
4. **Token budget already satisfied**: The adapter's `--summarize` flag guarantees ≤2000 tokens. No manual summarization needed.
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ failed_layers: '' # set at runtime: comma-separated list of layers that failed o
|
|||
> - **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."
|
||||
> - **If the spec/story file shows `--query get_impact` called WITHOUT `--summarize`** in a story that involves code modification → raise a `patch` finding: "Adapter called without --summarize — blast radius output may exceed 2000 token budget. NFR1 requires all Memtrace structural tool outputs to be under 2000 tokens."
|
||||
> - **If the spec/story file shows `memtrace-adapter.mjs` called for `get_impact` or `find_dead_code` WITHOUT `--check-freshness`** → raise a `patch` finding: "Adapter called without --check-freshness — architecture requires index freshness verification before trusting graph output (Cross-Cutting Concern: Index Freshness Check)."
|
||||
>
|
||||
> **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."
|
||||
|
|
|
|||
|
|
@ -7,18 +7,24 @@ import { resolve } from 'node:path';
|
|||
const TIMEOUT_MS = parseInt(process.env.MEMTRACE_TIMEOUT_MS || '10000', 10);
|
||||
const TIMEOUT_TOKEN = 'MEMTRACE_MCP_ERROR_TIMEOUT';
|
||||
const SUMMARIZE_TOKEN_LIMIT = 2000;
|
||||
const FRESHNESS_MAX_AGE_MINUTES = (() => {
|
||||
const env = parseInt(process.env.MEMTRACE_FRESHNESS_MAX_AGE_MINUTES, 10);
|
||||
return (Number.isFinite(env) && env > 0) ? env : 30;
|
||||
})();
|
||||
|
||||
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 <symbol> --query <type> [--repo <repo_id>] [--summarize]
|
||||
console.log(`Usage: node memtrace-adapter.mjs --target <symbol> --query <type> [--repo <repo_id>] [--summarize] [--check-freshness] [--batch]
|
||||
|
||||
Arguments:
|
||||
--target <symbol> Symbol name or file path to query (required for get_impact, find_dead_code)
|
||||
--target <symbol> Symbol name or file path to query (required for get_impact, find_dead_code). Repeatable with --batch.
|
||||
--query <type> Query type: get_impact, find_dead_code, list_repos (required)
|
||||
--repo <repo_id> Repository ID (optional — auto-detected from .memtrace-workspace if omitted)
|
||||
--summarize (Optional) Apply token-budgeted hierarchical summarization for --query get_impact (output ≤ 2000 tokens)
|
||||
--check-freshness (Optional) Verify index freshness before main query (blocks if stale)
|
||||
--batch (Optional) Process multiple --target values sequentially (anti-Promise.all)
|
||||
|
||||
Query types:
|
||||
get_impact Fetch structural blast radius for a target symbol
|
||||
|
|
@ -30,20 +36,27 @@ Examples:
|
|||
node memtrace-adapter.mjs --target "validateToken" --query get_impact --summarize
|
||||
node memtrace-adapter.mjs --query list_repos
|
||||
node memtrace-adapter.mjs --target "src/auth" --query find_dead_code
|
||||
node memtrace-adapter.mjs --target "sym1,sym2" --query get_impact --batch --check-freshness
|
||||
node memtrace-adapter.mjs --help`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const result = { target: null, query: null, repo: null, summarize: false };
|
||||
const result = { target: null, query: null, repo: null, summarize: false, checkFreshness: false, batch: false, targets: [] };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--target' && i + 1 < args.length) {
|
||||
result.target = args[++i];
|
||||
const val = args[++i];
|
||||
result.targets.push(val);
|
||||
result.target = val; // keep last for backward compat in non-batch mode
|
||||
} 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 if (args[i] === '--summarize') {
|
||||
result.summarize = true;
|
||||
} else if (args[i] === '--check-freshness') {
|
||||
result.checkFreshness = true;
|
||||
} else if (args[i] === '--batch') {
|
||||
result.batch = true;
|
||||
} else {
|
||||
fail(`Unknown argument: ${args[i]}`);
|
||||
process.exit(1);
|
||||
|
|
@ -72,6 +85,17 @@ Examples:
|
|||
}
|
||||
}
|
||||
|
||||
// Batch mode: parse multiple targets
|
||||
if (result.batch && result.targets.length > 0) {
|
||||
// Comma-separated: --target "sym1, sym2, sym3"
|
||||
if (result.targets.some(t => t.includes(','))) {
|
||||
const expanded = result.targets.flatMap(t => t.split(',').map(s => s.trim()).filter(Boolean));
|
||||
result.targets = expanded.length > 0 ? expanded : result.targets.filter(Boolean);
|
||||
}
|
||||
// Filter out any empty strings from repeated --target flags
|
||||
result.targets = result.targets.filter(t => t.length > 0);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -160,7 +184,10 @@ class McpClient {
|
|||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// Partial JSON in buffer — wait for more data
|
||||
// Line starts with { but isn't valid JSON — likely malformed, not partial
|
||||
if (line.trim().startsWith('{')) {
|
||||
console.error(`WARNING: MCP response parse error (may be malformed): ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -207,17 +234,41 @@ function resolveRepoId(args) {
|
|||
|
||||
// Try to auto-detect from .memtrace-workspace
|
||||
let cwd = process.cwd();
|
||||
const parts = cwd.split(/[\\/]/);
|
||||
const parts = cwd.split(/[\\/]/).filter(Boolean);
|
||||
// Try each ancestor directory, walking up
|
||||
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];
|
||||
const candidate = process.platform === 'win32'
|
||||
? resolve(dir || parts[0], '.memtrace-workspace')
|
||||
: resolve('/', dir, '.memtrace-workspace');
|
||||
if (existsSync(candidate)) {
|
||||
return parts[i - 1] || 'project';
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use CWD basename
|
||||
return parts[parts.length - 1];
|
||||
return parts[parts.length - 1] || 'project';
|
||||
}
|
||||
|
||||
async function checkIndexFreshness(client, repoId) {
|
||||
const listResult = await client.callTool('list_indexed_repositories', {});
|
||||
const repos = Array.isArray(listResult?.repos) ? listResult.repos : [];
|
||||
const match = repos.find(r => r && r.repo_id === repoId);
|
||||
|
||||
if (!match) {
|
||||
return { found: false, repo_id: repoId, last_indexed: null, age_minutes: null, is_fresh: false };
|
||||
}
|
||||
|
||||
const lastIndexed = match.last_indexed_at || match.last_indexed;
|
||||
if (lastIndexed == null) {
|
||||
return { found: true, repo_id: repoId, last_indexed: null, age_minutes: null, is_fresh: false };
|
||||
}
|
||||
|
||||
const ageMinutes = Math.round((Date.now() - Date.parse(lastIndexed)) / 60000 * 10) / 10;
|
||||
const valid = Number.isFinite(ageMinutes);
|
||||
const isFresh = valid && ageMinutes <= FRESHNESS_MAX_AGE_MINUTES;
|
||||
|
||||
return { found: true, repo_id: repoId, last_indexed: lastIndexed, age_minutes: valid ? ageMinutes : null, is_fresh: isFresh };
|
||||
}
|
||||
|
||||
async function queryGetImpact(client, target, repoId) {
|
||||
|
|
@ -241,7 +292,8 @@ async function queryFindDeadCode(client, target, repoId) {
|
|||
repo_id: repoId,
|
||||
file_path: target
|
||||
});
|
||||
const symbols = (result?.symbols || []).map(s => ({
|
||||
const raw = result?.symbols;
|
||||
const symbols = (Array.isArray(raw) ? raw : []).map(s => ({
|
||||
name: s.name || '<unknown>',
|
||||
kind: s.kind || 'Function',
|
||||
file: s.file || '',
|
||||
|
|
@ -261,11 +313,22 @@ async function queryListRepos(client) {
|
|||
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
|
||||
})),
|
||||
repositories: repos.map(r => {
|
||||
const repoId = r.repo_id;
|
||||
const lastIndexed = r.last_indexed_at || r.last_indexed || null;
|
||||
let ageMinutes = null;
|
||||
let isFresh = false;
|
||||
if (lastIndexed) {
|
||||
ageMinutes = Math.round((Date.now() - Date.parse(lastIndexed)) / 60000 * 10) / 10;
|
||||
isFresh = ageMinutes <= FRESHNESS_MAX_AGE_MINUTES;
|
||||
}
|
||||
return {
|
||||
repo_id: repoId,
|
||||
last_indexed: lastIndexed,
|
||||
total_nodes: r.total_nodes ?? r.nodes ?? 0,
|
||||
freshness: { age_minutes: ageMinutes, is_fresh: isFresh }
|
||||
};
|
||||
}),
|
||||
elapsed_ms: 0
|
||||
};
|
||||
}
|
||||
|
|
@ -325,6 +388,7 @@ function summarizeBlastRadius(result) {
|
|||
summarized.token_estimate = estimateTokens(summarized);
|
||||
|
||||
while (summarized.token_estimate > SUMMARIZE_TOKEN_LIMIT) {
|
||||
const prevEstimate = summarized.token_estimate;
|
||||
const cur = summarized.critical_dependents.length;
|
||||
if (cur > STAGE_CRITICAL) {
|
||||
summarized.critical_dependents = summarized.critical_dependents.slice(0, STAGE_CRITICAL);
|
||||
|
|
@ -343,6 +407,7 @@ function summarizeBlastRadius(result) {
|
|||
summarized.module_impact = Object.fromEntries(entries);
|
||||
}
|
||||
summarized.token_estimate = estimateTokens(summarized);
|
||||
if (summarized.token_estimate === prevEstimate) break; // no reduction possible — exit
|
||||
}
|
||||
|
||||
return summarized;
|
||||
|
|
@ -365,18 +430,30 @@ class TimeoutError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs();
|
||||
const start = Date.now();
|
||||
|
||||
if (args.summarize && args.query !== 'get_impact') {
|
||||
console.error('WARNING: --summarize is only applicable to --query get_impact. Ignored.');
|
||||
args.summarize = false;
|
||||
async function runFreshnessCheck(repoId) {
|
||||
const freshClient = new McpClient();
|
||||
let freshness;
|
||||
try {
|
||||
await freshClient.spawn();
|
||||
await withTimeout(freshClient.handshake(), TIMEOUT_MS);
|
||||
freshness = await withTimeout(checkIndexFreshness(freshClient, repoId), TIMEOUT_MS);
|
||||
const ageStr = freshness.age_minutes !== null ? `${freshness.age_minutes}m` : 'unknown';
|
||||
console.error(`[FRESHNESS] repo=${freshness.repo_id} age=${ageStr} fresh=${freshness.is_fresh}`);
|
||||
} catch (err) {
|
||||
freshClient.kill();
|
||||
console.error(`[FRESHNESS] ERROR: ${err.message}`);
|
||||
return { found: false, repo_id: repoId, age_minutes: null, is_fresh: false };
|
||||
}
|
||||
try {
|
||||
await withTimeout(freshClient.shutdown(), 5000);
|
||||
} catch {
|
||||
// Shutdown errors are non-fatal — freshness result is already determined
|
||||
}
|
||||
return freshness;
|
||||
}
|
||||
|
||||
const repoId = resolveRepoId(args);
|
||||
async function runSingleQuery(args, repoId, start) {
|
||||
const client = new McpClient();
|
||||
|
||||
try {
|
||||
await client.spawn();
|
||||
await withTimeout(client.handshake(), TIMEOUT_MS);
|
||||
|
|
@ -422,4 +499,108 @@ async function main() {
|
|||
}
|
||||
}
|
||||
|
||||
async function runBatchQuery(args, repoId, start) {
|
||||
const results = [];
|
||||
let totalSucceeded = 0;
|
||||
let totalFailed = 0;
|
||||
|
||||
for (const target of args.targets) {
|
||||
const targetStart = Date.now();
|
||||
const batchClient = new McpClient();
|
||||
try {
|
||||
await batchClient.spawn();
|
||||
await withTimeout(batchClient.handshake(), TIMEOUT_MS);
|
||||
|
||||
let queryFn;
|
||||
if (args.query === 'get_impact') {
|
||||
queryFn = queryGetImpact(batchClient, target, repoId);
|
||||
} else if (args.query === 'find_dead_code') {
|
||||
queryFn = queryFindDeadCode(batchClient, target, repoId);
|
||||
} else {
|
||||
throw new Error(`Batch mode does not support --query ${args.query}`);
|
||||
}
|
||||
|
||||
let result = await withTimeout(queryFn, TIMEOUT_MS);
|
||||
result.elapsed_ms = Date.now() - targetStart;
|
||||
|
||||
if (args.summarize && args.query === 'get_impact') {
|
||||
result.summarized = summarizeBlastRadius(result);
|
||||
}
|
||||
|
||||
await withTimeout(batchClient.shutdown(), 5000);
|
||||
results.push({ target, ...result });
|
||||
totalSucceeded++;
|
||||
} catch (err) {
|
||||
batchClient.kill();
|
||||
results.push({ target, error: err.message });
|
||||
totalFailed++;
|
||||
}
|
||||
}
|
||||
|
||||
const output = {
|
||||
query: args.query,
|
||||
targets: args.targets,
|
||||
results,
|
||||
total_succeeded: totalSucceeded,
|
||||
total_failed: totalFailed,
|
||||
elapsed_ms: Date.now() - start
|
||||
};
|
||||
|
||||
try {
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
} catch (serializeErr) {
|
||||
fail(`Failed to serialize result: ${serializeErr.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(totalFailed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs();
|
||||
const start = Date.now();
|
||||
|
||||
if (args.summarize && args.query !== 'get_impact') {
|
||||
console.error('WARNING: --summarize is only applicable to --query get_impact. Ignored.');
|
||||
args.summarize = false;
|
||||
}
|
||||
|
||||
const repoId = resolveRepoId(args);
|
||||
|
||||
// Pre-flight freshness check (before main MCP session)
|
||||
if (args.checkFreshness) {
|
||||
const freshness = await runFreshnessCheck(repoId);
|
||||
if (!freshness.found || !freshness.is_fresh) {
|
||||
// For list_repos: emit actual repo list as diagnostic before exiting (AC #4)
|
||||
if (args.query === 'list_repos') {
|
||||
const diagClient = new McpClient();
|
||||
try {
|
||||
await diagClient.spawn();
|
||||
await withTimeout(diagClient.handshake(), TIMEOUT_MS);
|
||||
const diagResult = await queryListRepos(diagClient);
|
||||
diagResult.freshness_error = freshness.found ? 'stale_index' : 'repo_not_found';
|
||||
diagResult.elapsed_ms = Date.now() - start;
|
||||
await withTimeout(diagClient.shutdown(), 5000);
|
||||
console.log(JSON.stringify(diagResult, null, 2));
|
||||
} catch (diagErr) {
|
||||
diagClient.kill();
|
||||
fail(`Failed to emit diagnostic: ${diagErr.message}`);
|
||||
}
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Batch mode: process targets sequentially
|
||||
if (args.batch) {
|
||||
if (!args.targets || args.targets.length === 0) {
|
||||
fail('--batch requires at least one --target value');
|
||||
process.exit(1);
|
||||
}
|
||||
await runBatchQuery(args, repoId, start);
|
||||
} else {
|
||||
await runSingleQuery(args, repoId, start);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
|
|||
|
|
@ -85,6 +85,16 @@ describe('memtrace-adapter.mjs', () => {
|
|||
const r = await runAdapter(['--target', 'foo', '--query', 'get_impact', '--summarize']);
|
||||
assert.ok(!r.stderr.includes('Unknown argument'), '--summarize should not cause unknown argument error');
|
||||
});
|
||||
|
||||
it('should accept --check-freshness as a valid flag', async () => {
|
||||
const r = await runAdapter(['--target', 'foo', '--query', 'get_impact', '--check-freshness']);
|
||||
assert.ok(!r.stderr.includes('Unknown argument'), '--check-freshness should not cause unknown argument error');
|
||||
});
|
||||
|
||||
it('should accept --batch as a valid flag', async () => {
|
||||
const r = await runAdapter(['--target', 'foo', '--query', 'get_impact', '--batch']);
|
||||
assert.ok(!r.stderr.includes('Unknown argument'), '--batch should not cause unknown argument error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Summarization (--summarize)', () => {
|
||||
|
|
@ -95,6 +105,18 @@ describe('memtrace-adapter.mjs', () => {
|
|||
assert.ok(r.stdout.includes('--summarize'), 'Help text must document --summarize');
|
||||
});
|
||||
|
||||
it('--help output should mention --check-freshness', async () => {
|
||||
const r = await runAdapter(['--help']);
|
||||
assert.equal(r.code, 0);
|
||||
assert.ok(r.stdout.includes('--check-freshness'), 'Help text must document --check-freshness');
|
||||
});
|
||||
|
||||
it('--help output should mention --batch', async () => {
|
||||
const r = await runAdapter(['--help']);
|
||||
assert.equal(r.code, 0);
|
||||
assert.ok(r.stdout.includes('--batch'), 'Help text must document --batch');
|
||||
});
|
||||
|
||||
it('--summarize with find_dead_code should emit warning on STDERR, no summarized field', { timeout: 30000 }, async () => {
|
||||
const r = await runAdapter(['--target', 'src', '--query', 'find_dead_code', '--repo', 'Repos', '--summarize']);
|
||||
assert.ok(r.stderr.includes('WARNING'), 'STDERR must contain warning');
|
||||
|
|
@ -164,6 +186,60 @@ describe('memtrace-adapter.mjs', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Freshness (--check-freshness)', () => {
|
||||
|
||||
it('--help output should mention --check-freshness', async () => {
|
||||
const r = await runAdapter(['--help']);
|
||||
assert.equal(r.code, 0);
|
||||
assert.ok(r.stdout.includes('--check-freshness'));
|
||||
});
|
||||
|
||||
it('--check-freshness with list_repos should emit [FRESHNESS] on STDERR', { timeout: 30000 }, async () => {
|
||||
const r = await runAdapter(['--query', 'list_repos', '--check-freshness']);
|
||||
if (r.code === 0) {
|
||||
assert.ok(r.stderr.includes('[FRESHNESS]'), 'STDERR must contain [FRESHNESS] line');
|
||||
} else if (r.code === 1) {
|
||||
assert.ok(r.stderr.includes('[FRESHNESS]'), 'STDERR must contain [FRESHNESS] line even on stale index');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Batch mode (--batch)', () => {
|
||||
|
||||
it('--batch with comma-separated targets should produce results array', { timeout: 30000 }, async () => {
|
||||
const r = await runAdapter(['--target', 'bmad-dev-story,parseArgs', '--query', 'get_impact', '--repo', 'Repos', '--batch']);
|
||||
if (r.code === 0) {
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
assert.equal(parsed.query, 'get_impact');
|
||||
assert.ok(Array.isArray(parsed.targets));
|
||||
assert.ok(Array.isArray(parsed.results));
|
||||
assert.ok(typeof parsed.total_succeeded === 'number');
|
||||
assert.ok(typeof parsed.total_failed === 'number');
|
||||
assert.ok(parsed.results.length >= 1);
|
||||
assert.ok(parsed.results[0].target);
|
||||
} else if (r.code === 1) {
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
assert.ok(Array.isArray(parsed.results));
|
||||
} else {
|
||||
assert.ok(r.stdout.includes('MEMTRACE_MCP_ERROR_TIMEOUT'));
|
||||
}
|
||||
});
|
||||
|
||||
it('--batch with --summarize should give each result summarized field', { timeout: 30000 }, async () => {
|
||||
const r = await runAdapter(['--target', 'bmad-dev-story,parseArgs', '--query', 'get_impact', '--repo', 'Repos', '--batch', '--summarize']);
|
||||
if (r.code === 0) {
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
assert.ok(Array.isArray(parsed.results));
|
||||
for (const res of parsed.results) {
|
||||
if (res.risk_level) { // succeeded
|
||||
assert.ok(res.summarized, 'Each successful target should have summarized field');
|
||||
assert.ok(typeof res.summarized.total_affected === 'number');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP queries', () => {
|
||||
|
||||
it('should list repositories and return valid JSON with repos array', { timeout: 20000 }, async () => {
|
||||
|
|
@ -178,6 +254,12 @@ describe('memtrace-adapter.mjs', () => {
|
|||
assert.equal(parsed.query, 'list_repos');
|
||||
assert.ok(Array.isArray(parsed.repositories));
|
||||
assert.ok(typeof parsed.elapsed_ms === 'number');
|
||||
// Verify freshness is always computed (AC #3)
|
||||
for (const repo of parsed.repositories) {
|
||||
assert.ok(repo.freshness, 'Each repo must have freshness field');
|
||||
assert.ok(typeof repo.freshness.age_minutes === 'number' || repo.freshness.age_minutes === null);
|
||||
assert.equal(typeof repo.freshness.is_fresh, 'boolean');
|
||||
}
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue