* wip(#3217): rule-4 scope withholding — parked, two open findings Implemented but NOT shippable. An isolated review found buildStateFrontmatter still hardcodes SCOPE.COMPLETE, so state json reports percent 0 where roadmap analyze, stats and query progress all correctly report null on the same disk state - rule 4 reintroduced at a site this phase claims to close. Also: roadmap analyze emits scope complete beside progress_percent null with nothing explaining it. Parked to build Phase 4 (#3186) first, which is unblocked. Findings recorded in .gsd/phase/refactor-3217-completion-ratio-scoping/60-review.json. * fix(#3217): withhold the sync percentage on a non-complete scope The parked blocker is fixed - buildStateFrontmatter no longer hardcodes SCOPE.COMPLETE, and the prose Progress fallback is gated too, which was a second leak found while tracing the first. roadmap analyze exposes progress_scope so a consumer can tell WHY a percentage is absent from the JSON alone. Then a residual gap was reproduced rather than assumed. cmdStateSync carried the same hardcode behind a written reason claiming it did not reproduce. It did: on a TRUNCATED window and on UNSCOPED row 4, state sync wrote Progress 0 percent to 100 percent while state json, roadmap analyze, stats and query progress all withheld - and it persisted a self-contradictory file, body claiming 100 percent while its own frontmatter correctly omitted percent. The excuse was also wrong. syncRoadmapRaw is already parsed in that function and is exactly what produces a real scope, so there was a scope to pass. Threaded through listMilestonePhaseDirs; a non-complete scope now skips the write with a reason in changes. milestoneBounded stays as the orthogonal 1761 guard for row 5. Second time this epic a does-not-reproduce claim was too generous. Recorded in ADR Amendment 8 as a correction rather than a quiet rewrite. Verified on the remote runner. * test(#3217): give the withholding fixtures a resolvable scope 40 matrix failures, all fixture drift - no code regression. My own hypothesis that this was over-withholding was wrong and is recorded as such: the worry case, a plain ROADMAP with Phase entries and no version heading, resolves to complete exactly as ADR 7.1 says it should. The real causes were two fixture shapes. Most had no ROADMAP.md at all, which is unreadable via a pre-existing graceful path, and asserted a numeric percent. The five vscode, pi-extension, mcp-server and shell-projection failures were that shape - bare temp dirs using progress json as a reachability proxy while asserting typeof percent is number, which under rule 4 is now null. The rest had a version token in a title or heading with no STATE.md milestone pointer to resolve it, which is classification row 4, versioned but unresolved, so withholding is correct per the contract. Verified on the remote runner. * test(#3217): make the LM-tools reachability tests dispatch against their fixture The gsd_progress reachability test was never testing its fixture. invoke() resolves cwd from vscode.workspace.workspaceFolders by design (the real LanguageModelToolInvocationOptions has no cwd field, per the 2103 fix in extension.js), the mock had no workspace at all, and the test passed a cwd option nothing reads - so it dispatched against the repo working directory. Writing a ROADMAP into the temp dir had no effect. Rule 4 only made it visible. Fixed by mocking workspaceFolders. The two siblings in the same file carried the identical dead cwd and were dispatching against the repo too; they were not failing only because their assertions did not touch scope-dependent output. Both now use their own fixture with assertions unchanged - the no-planning fallback paths already satisfy them honestly. Re-scanned the other five reachability files: no further instances. They thread cwd into parameters that genuinely read it, not through an options shape that ignores it. Verified on the remote runner. * chore(#3217): backfill changeset PR number pr:0 placeholder replaced with the real number now that #3318 exists. * ci(#3217): give the coverage merge enough heap for the merged shards The coverage gate OOMed at exit 134. c8 report merges three shard artifacts, roughly 358MB of V8 dumps in coverage/tmp, and died holding their per-file position maps at the ~4GB default heap. Verified as this branch's delta rather than pre-existing: the same job succeeded on next at 14:18, after phases 4 and 5 merged. Both coverage-gate steps get the bump because both re-slice the same merged data. 8192 doubles what failed and leaves headroom on a 16GB ubuntu runner, matching the idiom the shard step already uses at 6144. This is a memory bound, not a change to what is measured. No threshold was touched. The test file was checked for gratuitous subprocess spawning and is already reasonable at 43 spawns, each a distinct fixture-by-surface pairing. Verified on the remote runner. --------- Co-authored-by: sim <sim@local>
148 lines
7.7 KiB
JavaScript
148 lines
7.7 KiB
JavaScript
'use strict';
|
|
/**
|
|
* Tests for the companion MCP server (ADR-1239 Phase C-2, #1681 slice 3a).
|
|
* Pins: initialize handshake, tools/list, tools/call dispatch to the hub +
|
|
* stateIO seam, method-not-found, notification = no response, parse error in
|
|
* runServer, and a full injectable-stream round-trip.
|
|
*/
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { Readable } = require('node:stream');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const {
|
|
handleMessage,
|
|
runServer,
|
|
PROTOCOL_VERSION,
|
|
SERVER_NAME,
|
|
} = require('../gsd-core/bin/lib/mcp-server.cjs');
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
|
|
test('initialize: returns protocolVersion + capabilities + serverInfo', () => {
|
|
const res = handleMessage({ jsonrpc: '2.0', id: 1, method: 'initialize' });
|
|
assert.strictEqual(res.jsonrpc, '2.0');
|
|
assert.strictEqual(res.id, 1);
|
|
assert.strictEqual(res.result.protocolVersion, PROTOCOL_VERSION);
|
|
assert.ok(res.result.capabilities && res.result.capabilities.tools, 'must advertise tools capability');
|
|
assert.strictEqual(res.result.serverInfo.name, SERVER_NAME);
|
|
});
|
|
|
|
test('tools/list: advertises the 3 interface-point tools', () => {
|
|
const res = handleMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' });
|
|
const names = res.result.tools.map((t) => t.name);
|
|
assert.deepStrictEqual(names.sort(), ['gsd_invoke_command', 'gsd_read_state', 'gsd_write_state']);
|
|
});
|
|
|
|
test('tools/call gsd_read_state + gsd_write_state: round-trip through the stateIO seam (point 5)', () => {
|
|
const dir = createTempDir();
|
|
try {
|
|
const file = path.join(dir, 'STATE.md');
|
|
const writeRes = handleMessage({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'gsd_write_state', arguments: { path: file, content: '# State\n' } } });
|
|
assert.strictEqual(writeRes.result.isError, undefined, 'write must succeed');
|
|
assert.strictEqual(fs.readFileSync(file, 'utf-8'), '# State\n', 'write went through to fs');
|
|
const readRes = handleMessage({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'gsd_read_state', arguments: { path: file } } });
|
|
assert.strictEqual(readRes.result.content[0].text, '# State\n', 'read returns the written content');
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('tools/call gsd_invoke_command: dispatches to the command hub (point 1); unknown family returns a hub error, not a crash', () => {
|
|
const res = handleMessage({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'gsd_invoke_command', arguments: { family: 'no-such-family', subcommand: 'x' } } }, { cwd: createTempDirClean() });
|
|
// The hub returns a structured result (ok:false unknown-command) surfaced as text content, not a JSON-RPC error.
|
|
assert.strictEqual(res.jsonrpc, '2.0');
|
|
const payload = JSON.parse(res.result.content[0].text);
|
|
assert.strictEqual(payload.ok, false, 'an unknown command dispatches to the hub and returns ok:false');
|
|
assert.strictEqual(res.result.isError, true, 'unknown family surfaces as isError:true (#2102)');
|
|
});
|
|
|
|
// REGRESSION #2102: gsd_invoke_command previously called
|
|
// `commandRoutingHub.createHub()` with NO arguments, which always hits
|
|
// `if (!_cjsRegistry) return makeUnknownCommand()` — every dispatch, valid
|
|
// family or not, returned UnknownCommand. The test above (family:
|
|
// 'no-such-family') could not catch this: an UnknownCommand result is
|
|
// EXACTLY what an unknown family is supposed to return, whether or not
|
|
// dispatch actually worked — it is vacuous by construction. This test proves
|
|
// the fix: a VALID read-only family/subcommand must reach gsd-tools.cjs for
|
|
// real and come back with actual data (not a crash, not UnknownCommand).
|
|
test('tools/call gsd_invoke_command: REGRESSION #2102 — a valid read-only family dispatches for real (not the createHub()-with-no-args UnknownCommand bug)', () => {
|
|
const dir = createTempDir();
|
|
try {
|
|
// #3217 (ADR-3180 §7.6 rule 4): a free-form ROADMAP.md (no version
|
|
// token) is COMPLETE scope for windowing (§7.1) — without this, a
|
|
// bare temp dir has no ROADMAP.md at all (UNREADABLE) and `percent`
|
|
// is withheld (null), breaking this reachability proxy.
|
|
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
|
const res = handleMessage(
|
|
{ jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name: 'gsd_invoke_command', arguments: { family: 'progress', subcommand: 'json' } } },
|
|
{ cwd: dir },
|
|
);
|
|
assert.strictEqual(res.jsonrpc, '2.0');
|
|
assert.notStrictEqual(res.result.isError, true, 'a valid family must not surface as an error');
|
|
const text = res.result.content[0].text;
|
|
const parsed = JSON.parse(text);
|
|
// Fail-first proof: under the bug, this would be
|
|
// { ok: false, kind: 'UnknownCommand', command: 'progress json' } instead
|
|
// of the real progress payload — `percent` would not exist.
|
|
assert.strictEqual(typeof parsed.percent, 'number', 'the real "progress json" command ran (the engine was reached)');
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('tools/call: unknown tool name surfaces a tool error (isError), not a JSON-RPC protocol error', () => {
|
|
const res = handleMessage({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'gsd_bogus' } });
|
|
assert.strictEqual(res.result.isError, true);
|
|
assert.match(res.result.content[0].text, /Unknown tool/);
|
|
});
|
|
|
|
test('tools/call: missing tool name is a JSON-RPC invalid-params error', () => {
|
|
const res = handleMessage({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: {} });
|
|
assert.strictEqual(res.error.code, -32602);
|
|
assert.match(res.error.message, /requires string "name"/);
|
|
});
|
|
|
|
// Previously used 'resources/read' as the example unknown method, but that
|
|
// stopped being unknown once the served catalog shipped in #3072.
|
|
// 'resources/subscribe' is DELIBERATELY not implemented and deliberately NOT
|
|
// advertised in initialize's capabilities, because the server never sends the
|
|
// corresponding notification. So this assertion now pins a real contract --
|
|
// the advertised capability surface and the implemented method surface agree
|
|
// -- rather than an arbitrary method name that a future feature could
|
|
// invalidate the same way.
|
|
test('unknown method: JSON-RPC method-not-found (-32601)', () => {
|
|
const res = handleMessage({ jsonrpc: '2.0', id: 8, method: 'resources/subscribe' });
|
|
assert.strictEqual(res.error.code, -32601);
|
|
assert.match(res.error.message, /Method not found/);
|
|
});
|
|
|
|
test('notification (no id): returns null (no response per JSON-RPC)', () => {
|
|
assert.strictEqual(handleMessage({ jsonrpc: '2.0', method: 'initialize' }), null);
|
|
assert.strictEqual(handleMessage({ jsonrpc: '2.0', method: 'notifications/initialized' }), null);
|
|
});
|
|
|
|
test('runServer: line-delimited JSON-RPC round-trip over injectable streams', async () => {
|
|
const input = Readable.from([
|
|
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize' }) + '\n',
|
|
'not json\n',
|
|
JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }) + '\n',
|
|
]);
|
|
const out = [];
|
|
const output = { write: (s) => { out.push(s); return true; } };
|
|
await runServer({ input, output });
|
|
const joined = out.join('');
|
|
const responses = joined.trim().split('\n').map((l) => JSON.parse(l));
|
|
assert.strictEqual(responses.length, 3);
|
|
assert.strictEqual(responses[0].result.protocolVersion, PROTOCOL_VERSION, 'initialize handled');
|
|
assert.strictEqual(responses[1].error.code, -32700, 'parse error surfaced');
|
|
assert.ok(Array.isArray(responses[2].result.tools), 'tools/list handled');
|
|
});
|
|
|
|
// tiny helper to get a throwaway cwd without polluting the assertion helpers import above
|
|
function createTempDirClean() {
|
|
const os = require('node:os');
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-cwd-'));
|
|
}
|