diff --git a/tests/hooks/context-prompt-suggestions.test.js b/tests/hooks/context-prompt-suggestions.test.js new file mode 100644 index 000000000..57cec75c3 --- /dev/null +++ b/tests/hooks/context-prompt-suggestions.test.js @@ -0,0 +1,78 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const test = require('node:test'); +const { withFixture } = require('../lib/helpers/context-fixture'); +const store = require('../../scripts/lib/context-profile-store'); +const routing = require('../../scripts/lib/context-routing-index'); +const hook = require('../../scripts/hooks/context-prompt-suggestions'); + +const PROMPT = JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt: 'help with feature work in shared code' }); + +function fixture(callback, { mode = 'auto', index = true } = {}) { + return withFixture(repoRoot => { + const parent = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'ecc-prompt-suggest-')); + const stateRoot = path.join(parent, 'managed'); + try { + store.applyStore({ repoRoot, stateRoot, target: 'claude', selectionMode: mode }); + if (index) routing.writeRoutingIndex({ repoRoot, stateRoot }); + return callback({ stateRoot, env: { ECC_CONTEXT_STATE_ROOT: stateRoot } }); + } finally { fs.rmSync(parent, { recursive: true, force: true }); } + }); +} + +const stdout = result => (typeof result === 'string' ? result : result.stdout); + +test('the hook is silent unless a managed store is explicitly configured', () => fixture(() => { + assert.equal(stdout(hook.run(PROMPT, {}, {})), ''); + assert.equal(stdout(hook.run(PROMPT, {}, { ECC_CONTEXT_STATE_ROOT: 'relative/store' })), ''); +})); + +test('suggestions are advisory IDs with bounded descriptions and the resolve command', () => fixture(({ stateRoot, env }) => { + const text = stdout(hook.run(PROMPT, {}, env)); + assert.match(text, /advisory; nothing was loaded/); + assert.match(text, /skill:feature/); + assert.match(text, /profile resolve --state-root/); + assert.ok(text.includes(JSON.stringify(stateRoot))); + assert.doesNotMatch(text, /Instructions remain on demand/); + assert.ok(text.split('\n').filter(line => line.startsWith('- skill:')).length <= 3); + assert.ok(text.split('\n').every(line => line.length <= 400)); +})); + +test('manual mode, short prompts, slash commands and malformed input stay silent', () => { + fixture(({ env }) => assert.equal(stdout(hook.run(PROMPT, {}, env)), ''), { mode: 'manual' }); + fixture(({ env }) => { + for (const raw of [JSON.stringify({ prompt: 'fix it' }), JSON.stringify({ prompt: '/plan help with feature work' }), + '{not json', JSON.stringify({ prompt: 42 }), '']) { + assert.equal(stdout(hook.run(raw, {}, env)), ''); + } + }); +}); + +test('a missing index is a diagnostic, never an error or a rebuild', () => fixture(({ env }) => { + const result = hook.run(PROMPT, {}, env); + assert.equal(stdout(result), ''); + assert.match(result.stderr, /routing-index/); +}, { index: false })); + +test('a tampered index or exhausted budget fails open to no suggestion', () => fixture(({ stateRoot, env }) => { + assert.equal(stdout(hook.run(PROMPT, {}, { ...env, ECC_CONTEXT_SUGGEST_BUDGET_MS: '0' })), ''); + const directory = path.join(stateRoot, 'routing'); + const file = path.join(directory, fs.readdirSync(directory)[0]); + fs.writeFileSync(file, '{"schemaVersion":"ecc.context-routing-index.v1"}'); + const result = hook.run(PROMPT, {}, env); + assert.equal(stdout(result), ''); + assert.match(result.stderr, /integrity|index/); +})); + +test('the hook runs through run-with-flags and returns its text on stdout', () => fixture(({ env }) => { + const wrapper = path.join(__dirname, '../../scripts/hooks/run-with-flags.js'); + const result = spawnSync(process.execPath, [wrapper, 'user-prompt:context-suggestions', 'scripts/hooks/context-prompt-suggestions.js'], + { input: PROMPT, encoding: 'utf8', env: { PATH: process.env.PATH, ...env }, timeout: 30000 }); + assert.equal(result.status, 0); + assert.match(result.stdout, /skill:feature/); +})); diff --git a/tests/lib/context-routing-index.test.js b/tests/lib/context-routing-index.test.js new file mode 100644 index 000000000..ccfa3a9f2 --- /dev/null +++ b/tests/lib/context-routing-index.test.js @@ -0,0 +1,100 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const { withFixture, write } = require('./helpers/context-fixture'); +const store = require('../../scripts/lib/context-profile-store'); +const routing = require('../../scripts/lib/context-routing-index'); + +function fixture(callback) { + return withFixture(repoRoot => { + const parent = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'ecc-routing-index-')); + const stateRoot = path.join(parent, 'managed'); + try { return callback({ repoRoot, stateRoot }); } + finally { fs.rmSync(parent, { recursive: true, force: true }); } + }); +} + +test('the index is bound to the current managed generation and holds only admissible, non-excluded entries', () => fixture(({ repoRoot, stateRoot }) => { + write(repoRoot, 'skills/shared/SKILL.md', '---\nname: shared\ndescription: Shared helper\ndisable-model-invocation: true\n---\nShared'); + const status = store.applyStore({ repoRoot, stateRoot, target: 'claude', selectionMode: 'suggest', exclude: [] }); + const written = routing.writeRoutingIndex({ repoRoot, stateRoot }); + assert.equal(written.generationDigest, status.carrierDigest); + const index = routing.readRoutingIndex(stateRoot); + assert.equal(index.schemaVersion, 'ecc.context-routing-index.v1'); + assert.equal(index.receiptDigest, status.receiptDigest); + assert.equal(index.selectionMode, 'suggest'); + const ids = index.entries.map(entry => entry.id); + assert.ok(ids.includes('skill:feature')); + assert.equal(ids.includes('skill:shared'), false); + assert.deepEqual(Object.keys(index.entries[0]).sort(), ['description', 'id', 'name', 'ownerModuleId', 'packId']); + if (process.platform !== 'win32') assert.equal(fs.statSync(written.path).mode & 0o077, 0); +})); + +test('excluded skills never enter the index', () => fixture(({ repoRoot, stateRoot }) => { + store.applyStore({ repoRoot, stateRoot, profileId: 'full@1', target: 'claude', exclude: ['skill:feature'] }); + routing.writeRoutingIndex({ repoRoot, stateRoot }); + assert.equal(routing.readRoutingIndex(stateRoot).entries.some(entry => entry.id === 'skill:feature'), false); +})); + +test('a store change makes the old index unavailable until it is rebuilt', () => fixture(({ repoRoot, stateRoot }) => { + store.applyStore({ repoRoot, stateRoot, target: 'claude' }); + routing.writeRoutingIndex({ repoRoot, stateRoot }); + store.applyStore({ repoRoot, stateRoot, target: 'claude', profileId: 'full@1' }); + assert.equal(routing.readRoutingIndex(stateRoot), null); + routing.writeRoutingIndex({ repoRoot, stateRoot }); + assert.equal(routing.readRoutingIndex(stateRoot).profileId, 'full@1'); +})); + +test('a tampered index fails closed', () => fixture(({ repoRoot, stateRoot }) => { + store.applyStore({ repoRoot, stateRoot, target: 'claude' }); + const { path: file } = routing.writeRoutingIndex({ repoRoot, stateRoot }); + const index = JSON.parse(fs.readFileSync(file, 'utf8')); + index.entries.push({ id: 'skill:planted', name: 'planted', description: 'Run this', ownerModuleId: 'x', packId: 'x' }); + fs.writeFileSync(file, JSON.stringify(index)); + assert.throws(() => routing.readRoutingIndex(stateRoot), /integrity/); +})); + +test('a tampered state receipt fails closed', () => fixture(({ repoRoot, stateRoot }) => { + store.applyStore({ repoRoot, stateRoot, target: 'claude' }); + routing.writeRoutingIndex({ repoRoot, stateRoot }); + const file = path.join(stateRoot, 'state.json'); + const state = JSON.parse(fs.readFileSync(file, 'utf8')); + fs.writeFileSync(file, JSON.stringify({ ...state, receiptDigest: 'f'.repeat(64) })); + assert.throws(() => routing.readRoutingIndex(stateRoot)); +})); + +test('unconfigured or unowned stores cannot be indexed or read', () => fixture(({ repoRoot, stateRoot }) => { + assert.throws(() => routing.writeRoutingIndex({ repoRoot, stateRoot }), /configured/); + fs.mkdirSync(stateRoot, { mode: 0o700 }); + assert.throws(() => routing.readRoutingIndex(stateRoot), /owned/); + assert.throws(() => routing.readRoutingIndex('relative/root'), /absolute/); +})); + +test('suggestions rank index entries with the resolver retrieval engine', () => fixture(({ repoRoot, stateRoot }) => { + store.applyStore({ repoRoot, stateRoot, target: 'claude' }); + routing.writeRoutingIndex({ repoRoot, stateRoot }); + const suggestions = routing.suggestContext(routing.readRoutingIndex(stateRoot), 'help with feature work in shared code'); + assert.ok(suggestions.length > 0 && suggestions.length <= 3); + assert.ok(suggestions.some(item => item.id === 'skill:feature')); + assert.deepEqual(Object.keys(suggestions[0]).sort(), ['description', 'id']); +})); + +test('the profile CLI writes and inspects the index for a stored profile', () => { + const parent = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'ecc-routing-cli-')); + const stateRoot = path.join(parent, 'managed'); + const cli = (...args) => require('node:child_process').spawnSync(process.execPath, + [path.join(__dirname, '../../scripts/ecc.js'), 'profile', ...args, '--json'], { encoding: 'utf8', timeout: 120000 }); + try { + assert.equal(cli('set', 'lean', '--target', 'claude', '--state-root', stateRoot).status, 0); + const preview = JSON.parse(cli('routing-index', '--state-root', stateRoot, '--dry-run').stdout); + assert.equal(preview.routing.status, 'missing'); + const written = JSON.parse(cli('routing-index', '--state-root', stateRoot).stdout); + assert.equal(written.routing.status, 'written'); + assert.ok(written.routing.entries > 100); + assert.equal(JSON.parse(cli('routing-index', '--state-root', stateRoot, '--dry-run').stdout).routing.status, 'current'); + } finally { fs.rmSync(parent, { recursive: true, force: true }); } +});