diff --git a/commands/auto-update.md b/commands/auto-update.md index b1f39d17d..d685bd24a 100644 --- a/commands/auto-update.md +++ b/commands/auto-update.md @@ -11,7 +11,7 @@ Update ECC from its upstream repo and regenerate the current context's managed i ```bash # Preview the update without mutating anything -ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i !['name', 'arguments'].includes(key)) + // `_meta` is reserved by MCP for request metadata (e.g. progressToken); accept it, + // but when present it must be a metadata object — reject null, arrays, and scalars. + || (Object.prototype.hasOwnProperty.call(params, '_meta') && !isRecord(params._meta)) + || Object.keys(params).some(key => !['name', 'arguments', '_meta'].includes(key)) ) { return jsonRpcError(message.id, -32602, 'Unknown or missing memory tool.'); } diff --git a/tests/lib/command-plugin-root.test.js b/tests/lib/command-plugin-root.test.js index 9630d087d..287001fe9 100644 --- a/tests/lib/command-plugin-root.test.js +++ b/tests/lib/command-plugin-root.test.js @@ -50,6 +50,29 @@ test('instinct-status command uses shared inline resolver (no stale legacy fallb ); }); +test('auto-update command probes for the script it runs, not just scripts/lib', () => { + // A partial install can carry full resolver evidence (script tree plus + // sentinel ECC skill, per #2544/#2577) yet still lack scripts/auto-update.js. + // The command's inline resolver must probe for the script it actually + // executes so such roots don't shadow the complete plugin root. + const autoUpdateDocs = [ + path.join(__dirname, '..', '..', 'commands', 'auto-update.md'), + path.join(__dirname, '..', '..', 'docs', 'ja-JP', 'commands', 'auto-update.md'), + path.join(__dirname, '..', '..', 'docs', 'zh-CN', 'commands', 'auto-update.md'), + ]; + for (const docPath of autoUpdateDocs) { + const doc = fs.readFileSync(docPath, 'utf8'); + assert.strictEqual( + (doc.match(/scripts','lib','resolve-ecc-root/g) || []).length, 1, + `${docPath} should embed the shared inline resolver` + ); + assert.ok( + doc.includes("resolveEccRoot({probe:p.join('scripts','auto-update.js')})"), + `${docPath} should probe for scripts/auto-update.js` + ); + } +}); + test('resolveEccRoot module covers current and legacy marketplace plugin roots', () => { const { resolveEccRoot } = require('../../scripts/lib/resolve-ecc-root'); assert.ok(typeof resolveEccRoot === 'function'); diff --git a/tests/lib/github-coordination-branches.test.js b/tests/lib/github-coordination-branches.test.js index 5dbae1c43..75c84836e 100644 --- a/tests/lib/github-coordination-branches.test.js +++ b/tests/lib/github-coordination-branches.test.js @@ -25,24 +25,14 @@ const { verifyDependenciesClosed, } = require('../../scripts/lib/github-coordination/state'); -function test(name, fn) { - try { - fn(); - console.log(` ✓ ${name}`); - return true; - } catch (err) { - console.log(` ✗ ${name}`); - console.log(` Error: ${err.message}`); - return false; - } -} +const { test, banner, section, summary } = require('./helpers/mini-test-runner'); let passed = 0; let failed = 0; -console.log('\n=== parsing.js — uncovered branches ===\n'); +banner('parsing.js — uncovered branches'); -console.log('normalizeBodyForComparison:'); +section('normalizeBodyForComparison:'); if (test('handles null body (uses empty string fallback)', () => { const result = normalizeBodyForComparison(null); @@ -61,7 +51,7 @@ if (test('normalizes lastSyncAt timestamps in body text', () => { assert.ok(!result.includes('2024-01-01')); })) passed++; else failed++; -console.log('\nparseStringList:'); +section('parseStringList:'); if (test('returns empty array for null', () => { assert.deepStrictEqual(parseStringList(null), []); @@ -83,7 +73,7 @@ if (test('filters out empty parts from double-commas', () => { assert.deepStrictEqual(parseStringList('a,,b'), ['a', 'b']); })) passed++; else failed++; -console.log('\nmergeIssueBody — empty body branch:'); +section('mergeIssueBody — empty body branch:'); if (test('returns rendered state when issue body is empty string', () => { const state = { status: 'available', schemaVersion: 'v1', kind: 'epic', owner: null, branch: null, validation: 'pending', review: 'not-requested', project: { state: 'backlog', fields: {} }, dependencies: [], tasks: [], labels: [], lastAction: 'sync' }; @@ -97,9 +87,9 @@ if (test('returns rendered state when issue body is null', () => { assert.ok(result.includes('ecc-coordination:start')); })) passed++; else failed++; -console.log('\n=== state.js — uncovered branches ===\n'); +banner('state.js — uncovered branches'); -console.log('buildIssueStateFromAction — options absent (false branches):'); +section('buildIssueStateFromAction — options absent (false branches):'); const baseIssue = { number: 1, labels: [], body: '' }; const baseState = { @@ -133,7 +123,7 @@ if (test('buildIssueStateFromAction — currentState.tasks not array → re-extr assert.ok(Array.isArray(result.tasks)); })) passed++; else failed++; -console.log('\ndesiredLabelsForState — uncovered status/review/validation branches:'); +section('desiredLabelsForState — uncovered status/review/validation branches:'); if (test('includes published label for status "published"', () => { const labels = desiredLabelsForState({ status: 'published' }); @@ -160,7 +150,7 @@ if (test('includes review-changes-requested label for review "changes-requested" assert.ok(labels.includes('coordination:review-changes-requested')); })) passed++; else failed++; -console.log('\nmapStateToWorkItemStatus — uncovered switch cases:'); +section('mapStateToWorkItemStatus — uncovered switch cases:'); if (test('"validated" → "in-progress"', () => { assert.strictEqual(mapStateToWorkItemStatus('validated'), 'in-progress'); @@ -182,7 +172,7 @@ if (test('"unknown-state" → "open" (default)', () => { assert.strictEqual(mapStateToWorkItemStatus('unknown-state'), 'open'); })) passed++; else failed++; -console.log('\nassertIssueClaimable:'); +section('assertIssueClaimable:'); if (test('throws when issue is not open', () => { assert.throws( @@ -204,7 +194,7 @@ if (test('does not throw for open, unclaimed issue', () => { }); })) passed++; else failed++; -console.log('\nverifyDependenciesClosed:'); +section('verifyDependenciesClosed:'); if (test('returns empty array when dependencyNumbers is not an array', () => { const result = verifyDependenciesClosed('r/r', null, {}, []); @@ -240,7 +230,7 @@ if (test('warns via stderr and skips when dependency issue is not in allIssues l assert.ok(stderrOutput.includes('dependency issue #5 not found'), `expected stderr warning, got: ${stderrOutput}`); })) passed++; else failed++; -console.log('\ndefaultCoordinationState — edge branches:'); +section('defaultCoordinationState — edge branches:'); if (test('owner is null when issue has no author', () => { const result = defaultCoordinationState({ number: 1, labels: [] }); @@ -264,5 +254,4 @@ if (test('handles null issue', () => { assert.deepStrictEqual(result.tasks, []); })) passed++; else failed++; -console.log(`\n Results: ${passed} passed, ${failed} failed`); -if (failed > 0) process.exit(1); +summary(passed, failed); diff --git a/tests/lib/github-coordination-policy.test.js b/tests/lib/github-coordination-policy.test.js index 2143548be..0420789ef 100644 --- a/tests/lib/github-coordination-policy.test.js +++ b/tests/lib/github-coordination-policy.test.js @@ -19,17 +19,7 @@ const { DEFAULT_SECTION_MARKER, } = require('../../scripts/lib/github-coordination/policy'); -function test(name, fn) { - try { - fn(); - console.log(` ✓ ${name}`); - return true; - } catch (err) { - console.log(` ✗ ${name}`); - console.log(` Error: ${err.message}`); - return false; - } -} +const { test, banner, section, summary } = require('./helpers/mini-test-runner'); function withTempDir(fn) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-policy-test-')); @@ -51,9 +41,9 @@ function writeConfig(tmpDir, content) { let passed = 0; let failed = 0; -console.log('\n=== Testing github-coordination/policy.js ===\n'); +banner('Testing github-coordination/policy.js'); -console.log('loadPolicy — no config file:'); +section('loadPolicy — no config file:'); if (test('returns default policy when no config file exists in rootDir', () => { withTempDir(tmpDir => { @@ -74,7 +64,7 @@ if (test('returns default policy when custom configPath does not exist', () => { }); })) passed++; else failed++; -console.log('\nloadPolicy — configPath argument:'); +section('loadPolicy — configPath argument:'); if (test('uses configPath when explicitly provided', () => { withTempDir(tmpDir => { @@ -95,7 +85,7 @@ if (test('falls back to rootDir config file when configPath is null', () => { }); })) passed++; else failed++; -console.log('\nloadPolicy — invalid JSON:'); +section('loadPolicy — invalid JSON:'); if (test('throws on invalid JSON', () => { withTempDir(tmpDir => { @@ -104,7 +94,7 @@ if (test('throws on invalid JSON', () => { }); })) passed++; else failed++; -console.log('\nloadPolicy — non-object JSON:'); +section('loadPolicy — non-object JSON:'); if (test('throws when top-level JSON is null', () => { withTempDir(tmpDir => { @@ -127,7 +117,7 @@ if (test('throws when top-level JSON is a string', () => { }); })) passed++; else failed++; -console.log('\nloadPolicy — labels merging:'); +section('loadPolicy — labels merging:'); if (test('merges labels when parsed.labels is a plain object', () => { withTempDir(tmpDir => { @@ -162,7 +152,7 @@ if (test('falls back to empty labels when parsed.labels is a string', () => { }); })) passed++; else failed++; -console.log('\nloadPolicy — review merging:'); +section('loadPolicy — review merging:'); if (test('merges review when parsed.review is a plain object', () => { withTempDir(tmpDir => { @@ -197,7 +187,7 @@ if (test('falls back when parsed.review is an array', () => { }); })) passed++; else failed++; -console.log('\nloadPolicy — validation merging:'); +section('loadPolicy — validation merging:'); if (test('merges validation when parsed.validation is a plain object', () => { withTempDir(tmpDir => { @@ -215,7 +205,7 @@ if (test('falls back when parsed.validation is not an object', () => { }); })) passed++; else failed++; -console.log('\nloadPolicy — branchModel merging:'); +section('loadPolicy — branchModel merging:'); if (test('merges branchModel when parsed.branchModel is a plain object', () => { withTempDir(tmpDir => { @@ -234,7 +224,7 @@ if (test('falls back when parsed.branchModel is not an object', () => { }); })) passed++; else failed++; -console.log('\nloadPolicy — project merging:'); +section('loadPolicy — project merging:'); if (test('merges project when parsed.project is a plain object', () => { withTempDir(tmpDir => { @@ -261,7 +251,7 @@ if (test('falls back when parsed.project is null', () => { }); })) passed++; else failed++; -console.log('\nloadPolicy — project.fieldNames merging:'); +section('loadPolicy — project.fieldNames merging:'); if (test('merges fieldNames when project.fieldNames is a plain object', () => { withTempDir(tmpDir => { @@ -296,7 +286,7 @@ if (test('falls back when project.fieldNames is an array', () => { }); })) passed++; else failed++; -console.log('\nloadPolicy — sourcePath:'); +section('loadPolicy — sourcePath:'); if (test('sets sourcePath to the resolved config file path', () => { withTempDir(tmpDir => { @@ -306,5 +296,4 @@ if (test('sets sourcePath to the resolved config file path', () => { }); })) passed++; else failed++; -console.log(`\n Results: ${passed} passed, ${failed} failed`); -if (failed > 0) process.exit(1); +summary(passed, failed); diff --git a/tests/lib/github-coordination-store.test.js b/tests/lib/github-coordination-store.test.js index 9d23637dc..58f810cc7 100644 --- a/tests/lib/github-coordination-store.test.js +++ b/tests/lib/github-coordination-store.test.js @@ -16,17 +16,7 @@ const { const { DEFAULT_SCHEMA_VERSION, DEFAULT_POLICY } = require('../../scripts/lib/github-coordination/policy'); -function test(name, fn) { - try { - fn(); - console.log(` ✓ ${name}`); - return true; - } catch (err) { - console.log(` ✗ ${name}`); - console.log(` Error: ${err.message}`); - return false; - } -} +const { test, testAsync, banner, section, summary, fatal } = require('./helpers/mini-test-runner'); function makeStore() { const calls = []; @@ -42,15 +32,15 @@ function makeStore() { let passed = 0; let failed = 0; -console.log('\n=== Testing github-coordination/store.js ===\n'); +banner('Testing github-coordination/store.js'); -console.log('epicWorkItemId:'); +section('epicWorkItemId:'); if (test('produces a stable ID from repo and issue number', () => { assert.strictEqual(epicWorkItemId('acme/my-repo', 42), 'github-acme-my-repo-epic-42'); })) passed++; else failed++; -console.log('\nupsertCoordinationWorkItem — null store:'); +section('upsertCoordinationWorkItem — null store:'); if (test('returns null when store is null', () => { const result = upsertCoordinationWorkItem(null, 'r/r', { number: 1 }, {}, 'sync'); @@ -62,7 +52,7 @@ if (test('returns null when store is undefined', () => { assert.strictEqual(result, null); })) passed++; else failed++; -console.log('\nupsertCoordinationWorkItem — with store:'); +section('upsertCoordinationWorkItem — with store:'); if (test('passes schemaVersion from state when present', () => { const store = makeStore(); @@ -179,30 +169,17 @@ if (test('sets sessionId to null when options.sessionId absent', () => { assert.strictEqual(store.calls[0].sessionId, null); })) passed++; else failed++; -console.log('\nopenStore — dbPath: false:'); +section('openStore — dbPath: false:'); async function runAsyncTests() { - let asyncPassed = 0; - let asyncFailed = 0; - - try { + if (await testAsync('returns null when dbPath is false', async () => { const result = await openStore({ dbPath: false }); assert.strictEqual(result, null); - console.log(' ✓ returns null when dbPath is false'); - asyncPassed++; - } catch (err) { - console.log(' ✗ returns null when dbPath is false'); - console.log(` Error: ${err.message}`); - asyncFailed++; - } + })) passed++; else failed++; - const totalPassed = passed + asyncPassed; - const totalFailed = failed + asyncFailed; - console.log(`\n Results: ${totalPassed} passed, ${totalFailed} failed`); - if (totalFailed > 0) process.exit(1); + summary(passed, failed); } runAsyncTests().catch(err => { - console.error(`Unexpected async test failure: ${err.message}`); - process.exit(1); + fatal(`Unexpected async test failure: ${err.message}`); }); diff --git a/tests/lib/helpers/mini-test-runner.js b/tests/lib/helpers/mini-test-runner.js new file mode 100644 index 000000000..c653d9ed6 --- /dev/null +++ b/tests/lib/helpers/mini-test-runner.js @@ -0,0 +1,56 @@ +/** + * Shared mini test harness for standalone tests/lib/*.test.js scripts. + * + * Centralizes test execution and console reporting so individual test + * files don't duplicate the runner or log directly. + */ + +'use strict'; + +function report(message) { + console.log(message); +} + +function test(name, fn) { + try { + fn(); + report(` ✓ ${name}`); + return true; + } catch (err) { + report(` ✗ ${name}`); + report(` Error: ${err.message}`); + return false; + } +} + +async function testAsync(name, fn) { + try { + await fn(); + report(` ✓ ${name}`); + return true; + } catch (err) { + report(` ✗ ${name}`); + report(` Error: ${err.message}`); + return false; + } +} + +function banner(title) { + report(`\n=== ${title} ===`); +} + +function section(label) { + report(`\n${label}`); +} + +function summary(passed, failed) { + report(`\n Results: ${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); +} + +function fatal(message) { + console.error(message); + process.exit(1); +} + +module.exports = { test, testAsync, banner, section, summary, fatal }; diff --git a/tests/lib/resolve-ecc-root.test.js b/tests/lib/resolve-ecc-root.test.js index 36a8c0c13..25a53773c 100644 --- a/tests/lib/resolve-ecc-root.test.js +++ b/tests/lib/resolve-ecc-root.test.js @@ -363,6 +363,37 @@ function runTests() { } })) passed++; else failed++; + if (test('custom probe skips a qualifying root that lacks the probed script (auto-update)', () => { + // The surviving failure shape after #2544/#2577: a partial install can + // carry full resolver evidence (script tree + sentinel ECC skill) yet + // still lack the top-level script that auto-update will execute. The + // default probe rightly accepts such a root; a caller probing for the + // script it runs must skip it and reach the complete plugin root. + const homeDir = createTempDir(); + try { + const claudeDir = setupStandardInstall(homeDir); + const marketplaceRoot = setupLegacyPluginInstall(homeDir, ['marketplaces', 'ecc']); + fs.writeFileSync(path.join(marketplaceRoot, 'scripts', 'auto-update.js'), '// stub'); + + assert.strictEqual( + resolveEccRoot({ envRoot: '', homeDir }), + claudeDir, + 'default probe accepts a root with full resolver evidence' + ); + assert.strictEqual( + resolveEccRoot({ + envRoot: '', + homeDir, + probe: path.join('scripts', 'auto-update.js'), + }), + marketplaceRoot, + 'auto-update probe must skip roots that lack the script it will execute' + ); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + // ─── INLINE_RESOLVE ─── if (test('INLINE_RESOLVE is a non-empty string', () => { diff --git a/tests/lib/transcript-context.test.js b/tests/lib/transcript-context.test.js index 4f95c62b4..1d335f131 100644 --- a/tests/lib/transcript-context.test.js +++ b/tests/lib/transcript-context.test.js @@ -188,6 +188,10 @@ test('recognizes claude-mythos-5 as a 1M window from the known-model table (#246 assert.strictEqual(resolveContextWindowTokens(50000, 'claude-mythos-5'), LARGE_CONTEXT_WINDOW_TOKENS); }); +test('recognizes claude-opus-5 as a 1M window from the known-model table', () => { + assert.strictEqual(resolveContextWindowTokens(50000, 'claude-opus-5'), LARGE_CONTEXT_WINDOW_TOKENS); +}); + test('recognizes dated/prefixed variants of known large-window model ids (#2461)', () => { assert.strictEqual(resolveContextWindowTokens(50000, 'us.anthropic.claude-fable-5-20260115-v1:0'), LARGE_CONTEXT_WINDOW_TOKENS); }); diff --git a/tests/scripts/memory-mcp.test.js b/tests/scripts/memory-mcp.test.js index 2f540bf84..0a3a7a7a0 100644 --- a/tests/scripts/memory-mcp.test.js +++ b/tests/scripts/memory-mcp.test.js @@ -136,6 +136,7 @@ async function withClient(fn, options = {}) { 'tools/call', { name, arguments: toolArguments } ), + callToolRaw: params => request('tools/call', params), }; try { @@ -180,6 +181,40 @@ async function main() { }); }); + await test('accepts the reserved _meta param on tools/call and rejects malformed values', async () => { + await withClient(async client => { + // A valid `_meta` object (e.g. progressToken) must not block the tool call. + const withMeta = await client.callToolRaw({ + name: 'memory_doctor', + arguments: {}, + _meta: { progressToken: 'progress-123' }, + }); + assert.ok(Array.isArray(withMeta.content)); + + // Baseline: no `_meta` still works. + const withoutMeta = await client.callToolRaw({ + name: 'memory_doctor', + arguments: {}, + }); + assert.ok(Array.isArray(withoutMeta.content)); + + // A malformed `_meta` (null, array, or scalar) must be rejected. + for (const badMeta of [null, ['not', 'an', 'object'], 'string', 42, true]) { + await assert.rejects( + client.callToolRaw({ name: 'memory_doctor', arguments: {}, _meta: badMeta }), + /-32602/, + `expected _meta=${JSON.stringify(badMeta)} to be rejected` + ); + } + + // Unrelated top-level params must still be rejected. + await assert.rejects( + client.callToolRaw({ name: 'memory_doctor', arguments: {}, unexpected: true }), + /-32602/ + ); + }); + }); + await test('starts when the npm bin invokes the server through a symlink', async () => { const binRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-memory-bin-')); const binPath = path.join(binRoot, 'ecc-memory-mcp');