From c5784c52e945df3d4d29006e51215ed197688b20 Mon Sep 17 00:00:00 2001 From: James Chapman Date: Sun, 17 May 2026 14:57:09 +0100 Subject: [PATCH 1/7] Add Projects feature and comprehensive test coverage for untested modules - Add Projects tab with CRUD (server/lib/projects.js, router routes, UI tab/modal, store.js helpers) for per-project context scoping - Fix skills vanishing on mode apply: backfill missing skills in states GET/POST, SS.loadFromServer(), SS.applyServerStates() - Add Browse button for repo path in handoff modal and local folder linking in skills install modal - Change .fb button styling from transparent to dull purple for visibility - Add 6 new smoke tests (crypto, security, validation, backup, ranking, mutex) covering previously untested server modules - Extend 3 existing tests (chunker, vectorstore, handoffs) with edge cases: empty/corrupt/oversized inputs, replaceVectors, stale index lifecycle, cosine similarity boundaries, updateHandoff, getHandoff, path traversal, slug collisions --- package.json | 8 + scripts/backup-smoke.js | 133 ++++++++ scripts/chunker-smoke.js | 76 +++++ scripts/crypto-smoke.js | 103 ++++++ scripts/handoffs-smoke.js | 493 ++++++++++++++++------------ scripts/mutex-smoke.js | 109 ++++++ scripts/projects-smoke.js | 368 +++++++++++++++++++++ scripts/ranking-smoke.js | 105 ++++++ scripts/security-smoke.js | 130 ++++++++ scripts/validation-smoke.js | 102 ++++++ scripts/vectorstore-smoke.js | 89 ++++- server/lib/config.js | 4 + server/lib/handoff-migration.js | 1 - server/lib/handoffs.js | 5 +- server/lib/modes.js | 8 +- server/lib/projects.js | 121 +++++++ server/router.js | 44 ++- ui/app.js | 1 + ui/handoffs.js | 19 +- ui/index.html | 84 ++++- ui/modals.js | 10 + ui/modes.js | 2 +- ui/projects.js | 132 ++++++++ ui/skills-ingest.js | 59 ++++ ui/skills.js | 4 + ui/store.js | 43 ++- ui/styles/clean-slate/part-01.css | 14 +- ui/styles/dram-standard-actions.css | 25 +- ui/styles/primitives.css | 10 +- ui/styles/skills-final-ingest.css | 25 ++ 30 files changed, 2079 insertions(+), 248 deletions(-) create mode 100644 scripts/backup-smoke.js create mode 100644 scripts/crypto-smoke.js create mode 100644 scripts/mutex-smoke.js create mode 100644 scripts/projects-smoke.js create mode 100644 scripts/ranking-smoke.js create mode 100644 scripts/security-smoke.js create mode 100644 scripts/validation-smoke.js create mode 100644 server/lib/projects.js create mode 100644 ui/projects.js diff --git a/package.json b/package.json index 6f18219..9231a3a 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,14 @@ "test:skill-sources": "node scripts/skill-sources-smoke.js", "migrate:handoffs": "node scripts/migrate-legacy-handoff.js", "test:mcp-hosts": "node scripts/mcp-host-config-smoke.js", + "test:modes": "node scripts/mode-apply-smoke.js", + "test:projects": "node scripts/projects-smoke.js", + "test:crypto": "node scripts/crypto-smoke.js", + "test:security": "node scripts/security-smoke.js", + "test:validation": "node scripts/validation-smoke.js", + "test:backup": "node scripts/backup-smoke.js", + "test:ranking": "node scripts/ranking-smoke.js", + "test:mutex": "node scripts/mutex-smoke.js", "mcp": "node mcp-server.mjs", "mcp:http": "node mcp-http-server.mjs", "mcpb:pack": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/pack-mcpb.ps1", diff --git a/scripts/backup-smoke.js b/scripts/backup-smoke.js new file mode 100644 index 0000000..f1c3868 --- /dev/null +++ b/scripts/backup-smoke.js @@ -0,0 +1,133 @@ +// @ts-check + +// backup-smoke.js — Smoke test for backup, restore, and session logging + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-backup-')); +process.env.CE_ROOT = testRoot; +fs.mkdirSync(path.join(testRoot, 'data'), { recursive: true }); + +delete require.cache[require.resolve('../server/lib/config')]; +delete require.cache[require.resolve('../server/lib/backup')]; + +const { DATA_DIR, BACKUPS_DIR } = require('../server/lib/config'); +const { + readData, + writeData, + createBackup, + listBackups, + restoreBackup, + getSessionLog, + appendSession, +} = require('../server/lib/backup'); + +// ---- readData / writeData ---- + +// GIVEN no data files exist +// WHEN we read memory.json +const empty = readData('memory.json'); +assert.strictEqual(empty, null, 'readData returns null for missing file'); + +// GIVEN we write data +const memoryObj = { version: '1.1', entries: [{ content: 'User prefers dark mode', category: 'general' }] }; +writeData('memory.json', memoryObj); +// THEN we can read it back +const readBack = readData('memory.json'); +assert.deepStrictEqual(readBack, memoryObj, 'readData returns written data'); + +// GIVEN a corrupt JSON file +fs.writeFileSync(path.join(DATA_DIR, 'corrupt.json'), 'NOT JSON', 'utf8'); +const corrupt = readData('corrupt.json'); +assert.strictEqual(corrupt, null, 'readData returns null for corrupt file'); + +// ---- createBackup ---- + +// GIVEN existing data files +writeData('rules.json', { coding: 'Use strict', general: '', soul: '' }); +writeData('skill-states.json', { 'skill-a': true }); +// WHEN we create a backup +const backup1 = createBackup(); +assert.ok(backup1.timestamp, 'createBackup returns a timestamp'); +// THEN the backup directory exists +const backupDir1 = path.join(BACKUPS_DIR, String(backup1.timestamp)); +assert(fs.existsSync(backupDir1), 'backup directory is created'); +// AND it contains the data files +assert(fs.existsSync(path.join(backupDir1, 'memory.json')), 'backup includes memory.json'); +assert(fs.existsSync(path.join(backupDir1, 'rules.json')), 'backup includes rules.json'); +assert(fs.existsSync(path.join(backupDir1, 'skill-states.json')), 'backup includes skill-states.json'); + +// ---- listBackups ---- + +// WHEN we list backups +const backups = listBackups(); +assert.ok(Array.isArray(backups), 'listBackups returns an array'); +assert.strictEqual(backups.length, 1, 'one backup exists'); +const firstBackup = backups[0]; +assert.ok(firstBackup, 'first backup entry exists'); +assert.strictEqual(firstBackup.timestamp, String(backup1.timestamp), 'timestamp matches'); + +// GIVEN no backups directory +fs.rmSync(BACKUPS_DIR, { recursive: true, force: true }); +const noBackups = listBackups(); +assert.deepStrictEqual(noBackups, [], 'listBackups returns empty array when no dir'); + +// Recreate for restore test +writeData('memory.json', memoryObj); +writeData('rules.json', { coding: 'Use strict', general: '', soul: '' }); +writeData('skill-states.json', { 'skill-a': true }); +const backupForRestore = createBackup(); + +// ---- restoreBackup ---- + +// GIVEN we change the data +writeData('memory.json', { version: '1.1', entries: [] }); +const changed = readData('memory.json'); +assert.deepStrictEqual(changed.entries, [], 'data was changed'); +// WHEN we restore from backup +const restored = restoreBackup(backupForRestore.timestamp); +assert.strictEqual(restored, true, 'restoreBackup returns true for existing backup'); +// THEN the data is restored +const afterRestore = readData('memory.json'); +assert.deepStrictEqual(afterRestore, memoryObj, 'data restored from backup'); + +// WHEN we try to restore a nonexistent backup +const restoreMiss = restoreBackup('nonexistent-timestamp'); +assert.strictEqual(restoreMiss, false, 'restoreBackup returns false for missing backup'); + +// ---- getSessionLog ---- + +// GIVEN no session log +const emptyLog = getSessionLog(); +assert.ok(emptyLog.sessions, 'getSessionLog returns sessions array'); +assert.strictEqual(emptyLog.sessions.length, 0, 'empty log has no sessions'); + +// ---- appendSession ---- + +// WHEN we append a session entry +appendSession({ type: 'mode_apply', modeId: 'coding' }); +// THEN it appears in the log +const log1 = getSessionLog(); +assert.strictEqual(log1.sessions.length, 1, 'session log has one entry'); +assert.strictEqual(log1.sessions[0].type, 'mode_apply', 'session type preserved'); + +// WHEN we append more entries +for (let i = 0; i < 60; i++) { + appendSession({ type: 'bulk', index: i }); +} +// THEN the log is capped at 50 +const log2 = getSessionLog(); +assert.strictEqual(log2.sessions.length, 50, 'session log is capped at 50 entries'); + +// GIVEN a corrupt session log +fs.writeFileSync(path.join(DATA_DIR, 'session-log.json'), 'NOT JSON', 'utf8'); +const corruptLog = getSessionLog(); +assert.ok(corruptLog.sessions, 'getSessionLog returns sessions for corrupt file'); +assert.strictEqual(corruptLog.sessions.length, 0, 'corrupt log returns empty sessions'); + +// cleanup +fs.rmSync(testRoot, { recursive: true, force: true }); +console.log('backup smoke ok'); diff --git a/scripts/chunker-smoke.js b/scripts/chunker-smoke.js index 79688e1..3b323ae 100644 --- a/scripts/chunker-smoke.js +++ b/scripts/chunker-smoke.js @@ -95,3 +95,79 @@ assert( ); console.log(`chunker smoke ok: ${chunks.length + complexChunks.length} chunks`); + +// ---- Edge cases ---- + +// GIVEN empty content +// WHEN chunked +const emptyChunks = chunkSkillContent({ skillId: 'empty', sourcePath: 'empty/SKILL.md', content: '' }); +assert.deepStrictEqual(emptyChunks, [], 'empty content produces zero chunks'); + +// GIVEN content with no frontmatter +const noFrontmatterChunks = chunkSkillContent({ + skillId: 'no-fm', + sourcePath: 'no-fm/SKILL.md', + content: '# No Frontmatter\n\nJust a plain skill with no YAML block.\n', +}); +assert( + !noFrontmatterChunks.some((c) => c.section === 'Skill Manifest'), + 'no manifest chunk when frontmatter absent', +); +assert( + noFrontmatterChunks.some((c) => c.section === 'No Frontmatter'), + 'heading section is still produced', +); + +// GIVEN empty frontmatter (---\n---) +const emptyFmChunks = chunkSkillContent({ + skillId: 'empty-fm', + sourcePath: 'empty-fm/SKILL.md', + content: '---\n---\n\n# Hello\n\nBody text.\n', +}); +assert( + !emptyFmChunks.some((c) => c.section === 'Skill Manifest'), + 'empty frontmatter produces no manifest chunk', +); + +// GIVEN CRLF line endings +const crlfChunks = chunkSkillContent({ + skillId: 'crlf', + sourcePath: 'crlf/SKILL.md', + content: + '---\r\nname: CRLF Skill\r\ndescription: Windows line endings.\r\n---\r\n\r\n# CRLF Section\r\n\r\nAlways use CRLF.\r\n', +}); +assert( + crlfChunks.some((c) => c.section === 'CRLF Section'), + 'CRLF content is parsed correctly', +); +assert( + crlfChunks.some((c) => c.type === 'rule'), + 'CRLF content with "Always" is classified as rule', +); + +// GIVEN oversized content (> 2200 chars) +const longParagraph = 'A'.repeat(3000); +const oversizedChunks = chunkSkillContent({ + skillId: 'oversize', + sourcePath: 'oversize/SKILL.md', + content: `# Big Section\n\n${longParagraph}\n\n## Next\n\nSmall content.\n`, +}); +assert( + oversizedChunks.some((c) => c.section === 'Big Section'), + 'oversized section is still chunked', +); +assert( + oversizedChunks.some((c) => c.section === 'Next'), + 'section after oversized is preserved', +); + +// GIVEN content with multiple code blocks in one section +const multiCodeChunks = chunkSkillContent({ + skillId: 'multi-code', + sourcePath: 'multi-code/SKILL.md', + content: `# Examples\n\n\`\`\`js\nconsole.log('first');\n\`\`\`\n\n\`\`\`python\nprint('second')\n\`\`\`\n`, +}); +const exampleChunks = multiCodeChunks.filter((c) => c.type === 'example'); +assert.ok(exampleChunks.length >= 2, 'multiple code blocks produce multiple example chunks'); + +console.log('chunker smoke ok'); diff --git a/scripts/crypto-smoke.js b/scripts/crypto-smoke.js new file mode 100644 index 0000000..3c464ec --- /dev/null +++ b/scripts/crypto-smoke.js @@ -0,0 +1,103 @@ +// @ts-check + +// crypto-smoke.js — Smoke test for API key encryption/decryption + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-crypto-')); +process.env.CE_ROOT = testRoot; +fs.mkdirSync(path.join(testRoot, 'data'), { recursive: true }); + +delete require.cache[require.resolve('../server/lib/config')]; +delete require.cache[require.resolve('../server/lib/crypto')]; + +const { KEYS_FILE } = require('../server/lib/config'); +const { getApiKey, setApiKey, removeApiKey } = require('../server/lib/crypto'); + +// GIVEN no keys file exists +// WHEN we retrieve a key +const missing = getApiKey('CE_TEST_KEY'); +assert.strictEqual(missing, null, 'getApiKey returns null when no keys file exists'); + +// WHEN we store a key +setApiKey('CE_TEST_KEY', 'sk-secret-123'); +// THEN the key file exists +assert(fs.existsSync(KEYS_FILE), 'key file is created after setApiKey'); +// AND we can retrieve it +const retrieved = getApiKey('CE_TEST_KEY'); +assert.strictEqual(retrieved, 'sk-secret-123', 'getApiKey returns the stored value'); + +// GIVEN an environment variable with the same name +process.env.CE_TEST_KEY = 'env-override'; +// WHEN we retrieve the key +const envResult = getApiKey('CE_TEST_KEY'); +// THEN the env var takes precedence +assert.strictEqual(envResult, 'env-override', 'env var overrides stored key'); +delete process.env.CE_TEST_KEY; + +// WHEN we retrieve after env var is removed +const afterEnv = getApiKey('CE_TEST_KEY'); +assert.strictEqual(afterEnv, 'sk-secret-123', 'stored key used when env var is gone'); + +// WHEN we overwrite a key +setApiKey('CE_TEST_KEY', 'sk-new-value'); +const overwritten = getApiKey('CE_TEST_KEY'); +assert.strictEqual(overwritten, 'sk-new-value', 'setApiKey overwrites existing key'); + +// GIVEN multiple keys +setApiKey('CE_KEY_A', 'value-a'); +setApiKey('CE_KEY_B', 'value-b'); +// THEN both are retrievable +assert.strictEqual(getApiKey('CE_KEY_A'), 'value-a', 'first key preserved after second set'); +assert.strictEqual(getApiKey('CE_KEY_B'), 'value-b', 'second key stored correctly'); + +// WHEN we remove a key +removeApiKey('CE_KEY_A'); +// THEN it is gone +assert.strictEqual(getApiKey('CE_KEY_A'), null, 'removed key returns null'); +// AND the other key is unaffected +assert.strictEqual(getApiKey('CE_KEY_B'), 'value-b', 'other key unaffected by removal'); + +// WHEN we remove a key that does not exist +removeApiKey('CE_NONEXISTENT'); +// THEN no error is thrown and the file remains valid +assert.strictEqual(getApiKey('CE_KEY_B'), 'value-b', 'file still valid after removing nonexistent key'); + +// GIVEN a keys file with a corrupted entry (bad ciphertext) +const corruptKeys = JSON.parse(fs.readFileSync(KEYS_FILE, 'utf8')); +corruptKeys['CE_CORRUPT'] = { iv: '00', tag: '00', data: 'not-real-hex!' }; +fs.writeFileSync(KEYS_FILE, JSON.stringify(corruptKeys, null, 2), 'utf8'); +// WHEN we retrieve the corrupted key +const corruptResult = getApiKey('CE_CORRUPT'); +// THEN it returns null (graceful failure) +assert.strictEqual(corruptResult, null, 'getApiKey returns null for corrupted ciphertext'); + +// WHEN we retrieve a key that never existed +assert.strictEqual(getApiKey('CE_NEVER_SET'), null, 'getApiKey returns null for never-set key'); + +// GIVEN a key with special characters +const specialValue = 'key-with-quotes-"and-escapes\n\t\\'; +setApiKey('CE_SPECIAL', specialValue); +// WHEN retrieved +const specialResult = getApiKey('CE_SPECIAL'); +assert.strictEqual(specialResult, specialValue, 'special characters survive round-trip'); + +// GIVEN a key with unicode +const unicodeValue = 'k\u00e9y-\u00e9\u00e0\u00fc\u00f1'; +setApiKey('CE_UNICODE', unicodeValue); +const unicodeResult = getApiKey('CE_UNICODE'); +assert.strictEqual(unicodeResult, unicodeValue, 'unicode survives round-trip'); + +// GIVEN a keys file that is not valid JSON +fs.writeFileSync(KEYS_FILE, 'NOT JSON', 'utf8'); +// WHEN we try to set a new key (should overwrite) +setApiKey('CE_AFTER_CORRUPT', 'survives'); +const afterCorrupt = getApiKey('CE_AFTER_CORRUPT'); +assert.strictEqual(afterCorrupt, 'survives', 'setApiKey recovers from corrupt keys file'); + +// cleanup +fs.rmSync(testRoot, { recursive: true, force: true }); +console.log('crypto smoke ok'); diff --git a/scripts/handoffs-smoke.js b/scripts/handoffs-smoke.js index 2682073..07ac46c 100644 --- a/scripts/handoffs-smoke.js +++ b/scripts/handoffs-smoke.js @@ -15,6 +15,8 @@ const { createHandoff, listHandoffs, listArchived, + getHandoff, + updateHandoff, restoreHandoff, purgeHandoff, } = require('../server/lib/handoffs'); @@ -22,218 +24,287 @@ const { PROJECT_HANDOFF_RELATIVE, syncProjectHandoff } = require('../server/lib/ const { parseLegacyHandoff, migrateLegacyHandoff } = require('../server/lib/handoff-migration'); (async () => { -const active = createHandoff({ - title: 'Thread resume', - thread_tag: 'thread-resume', - body: 'Continue from the mocked thread state.', -}); -assert.strictEqual(active.ok, true, 'expected thread handoff to create'); -assert.strictEqual(active.handoff.type, 'thread'); -assert.strictEqual(active.handoff.slug, 'thread-resume'); -assert.strictEqual(listHandoffs().length, 1, 'expected active handoff in list'); - -const archived = fs.readFileSync(path.join(HANDOFFS_DIR, 'thread-resume.md'), 'utf8'); -fs.writeFileSync( - path.join(HANDOFFS_DIR, 'thread-resume.md'), - archived.replace(/last_touched: .+/, 'last_touched: 2020-01-01T00:00:00.000Z'), - 'utf8', -); -assert.strictEqual(listHandoffs().length, 0, 'idle thread handoff should auto-archive'); -assert.strictEqual(listArchived().length, 1, 'archived list should include stale thread handoff'); - -const restored = await restoreHandoff('thread-resume'); -assert.strictEqual(restored.ok, true, 'expected archived handoff to restore'); -assert.strictEqual(listHandoffs().length, 1, 'restored handoff should be active again'); - -const dual = createHandoff({ - title: 'Dual stale thread', - repo: tmpRoot, - thread_tag: 'dual-stale-thread', - body: 'A dual-bound handoff should archive when the thread is idle.', -}); -assert.strictEqual(dual.ok, true, 'expected dual handoff to create against existing directory'); -const dualPath = path.join(HANDOFFS_DIR, 'dual-stale-thread.md'); -fs.writeFileSync( - dualPath, - fs.readFileSync(dualPath, 'utf8').replace(/last_touched: .+/, 'last_touched: 2020-01-01T00:00:00.000Z'), - 'utf8', -); -listHandoffs(); -assert( - fs.existsSync(path.join(ARCHIVE_DIR, 'dual-stale-thread.md')), - 'dual-bound handoff should archive when thread is idle even if commit count is unavailable', -); - -const purged = await purgeHandoff('dual-stale-thread'); -assert.strictEqual(purged.ok, true, 'expected purge of archived handoff'); -assert(!fs.existsSync(path.join(ARCHIVE_DIR, 'dual-stale-thread.md')), 'purged handoff should be deleted'); - -const repoDir = path.join(tmpRoot, 'repo'); -fs.mkdirSync(repoDir); -/** @param {string[]} args */ -const git = (args) => execFileSync('git', args, { cwd: repoDir, stdio: ['ignore', 'pipe', 'ignore'] }); -git(['init']); -git(['config', 'user.email', 'context-engine@example.test']); -git(['config', 'user.name', 'Context Engine Test']); -fs.writeFileSync(path.join(repoDir, 'notes.txt'), 'baseline\n', 'utf8'); -git(['add', 'notes.txt']); -git(['commit', '-m', 'baseline']); -const project = createHandoff({ - title: 'Project timeline', - repo: repoDir, - thread_tag: 'project-timeline', - body: 'Track commits made after the handoff was written.', -}); -assert.strictEqual(project.ok, true, 'expected project handoff to create against git repo'); -fs.appendFileSync(path.join(repoDir, 'notes.txt'), 'first\n', 'utf8'); -git(['add', 'notes.txt']); -git(['commit', '-m', 'first change']); -fs.appendFileSync(path.join(repoDir, 'notes.txt'), 'second\n', 'utf8'); -git(['add', 'notes.txt']); -git(['commit', '-m', 'second change']); -const timelineHandoff = listHandoffs().find((handoff) => handoff.slug === 'project-timeline'); -assert(timelineHandoff, 'expected project timeline handoff to stay active under commit threshold'); -assert.strictEqual(timelineHandoff.staleness.commits_past_head, 2, 'expected two commits past handoff head'); - -// Restore-doesn't-re-archive regression: archive a project handoff via the -// commit threshold, then restore. The previous bug left head_sha pointing -// at the pre-archive sha so the next listHandoffs() would immediately re- -// archive on the same trip. Restore should refresh head_sha so the counter -// resets and the entry stays active. -const projectArchiveTarget = listHandoffs().find((h) => h.slug === 'project-timeline'); -assert(projectArchiveTarget, 'project handoff should exist before forced archive'); -const archiveResult = await require('../server/lib/handoffs').archiveHandoff('project-timeline'); -assert.strictEqual(archiveResult.ok, true, 'project handoff should archive on demand'); -// Advance the repo so commits_past_head against the old sha is > threshold. -for (let i = 0; i < 6; i++) { - fs.appendFileSync(path.join(repoDir, 'notes.txt'), `extra-${i}\n`, 'utf8'); + const active = createHandoff({ + title: 'Thread resume', + thread_tag: 'thread-resume', + body: 'Continue from the mocked thread state.', + }); + assert.strictEqual(active.ok, true, 'expected thread handoff to create'); + assert.strictEqual(active.handoff.type, 'thread'); + assert.strictEqual(active.handoff.slug, 'thread-resume'); + assert.strictEqual(listHandoffs().length, 1, 'expected active handoff in list'); + + const archived = fs.readFileSync(path.join(HANDOFFS_DIR, 'thread-resume.md'), 'utf8'); + fs.writeFileSync( + path.join(HANDOFFS_DIR, 'thread-resume.md'), + archived.replace(/last_touched: .+/, 'last_touched: 2020-01-01T00:00:00.000Z'), + 'utf8', + ); + assert.strictEqual(listHandoffs().length, 0, 'idle thread handoff should auto-archive'); + assert.strictEqual(listArchived().length, 1, 'archived list should include stale thread handoff'); + + const restored = await restoreHandoff('thread-resume'); + assert.strictEqual(restored.ok, true, 'expected archived handoff to restore'); + assert.strictEqual(listHandoffs().length, 1, 'restored handoff should be active again'); + + const dual = createHandoff({ + title: 'Dual stale thread', + repo: tmpRoot, + thread_tag: 'dual-stale-thread', + body: 'A dual-bound handoff should archive when the thread is idle.', + }); + assert.strictEqual(dual.ok, true, 'expected dual handoff to create against existing directory'); + const dualPath = path.join(HANDOFFS_DIR, 'dual-stale-thread.md'); + fs.writeFileSync( + dualPath, + fs.readFileSync(dualPath, 'utf8').replace(/last_touched: .+/, 'last_touched: 2020-01-01T00:00:00.000Z'), + 'utf8', + ); + listHandoffs(); + assert( + fs.existsSync(path.join(ARCHIVE_DIR, 'dual-stale-thread.md')), + 'dual-bound handoff should archive when thread is idle even if commit count is unavailable', + ); + + const purged = await purgeHandoff('dual-stale-thread'); + assert.strictEqual(purged.ok, true, 'expected purge of archived handoff'); + assert(!fs.existsSync(path.join(ARCHIVE_DIR, 'dual-stale-thread.md')), 'purged handoff should be deleted'); + + const repoDir = path.join(tmpRoot, 'repo'); + fs.mkdirSync(repoDir); + /** @param {string[]} args */ + const git = (args) => execFileSync('git', args, { cwd: repoDir, stdio: ['ignore', 'pipe', 'ignore'] }); + git(['init']); + git(['config', 'user.email', 'context-engine@example.test']); + git(['config', 'user.name', 'Context Engine Test']); + fs.writeFileSync(path.join(repoDir, 'notes.txt'), 'baseline\n', 'utf8'); git(['add', 'notes.txt']); - git(['commit', '-m', `extra ${i}`]); -} -const restoredProject = await restoreHandoff('project-timeline'); -assert.strictEqual(restoredProject.ok, true, 'archived project handoff should restore'); -// After restore + auto-sweep, the handoff must still be active (i.e. head_sha -// was refreshed; the commit counter is now zero against the new head). -const afterRestoreList = listHandoffs().map((h) => h.slug); -assert( - afterRestoreList.includes('project-timeline'), - 'restored project handoff should stay active — head_sha must be refreshed on restore', -); -const refreshed = listHandoffs().find((h) => h.slug === 'project-timeline'); -assert(refreshed, 'restored project handoff should appear in active list'); -assert.strictEqual( - refreshed.staleness.commits_past_head, - 0, - 'restored project handoff should report 0 commits past head', -); - -assert.strictEqual(timelineHandoff.staleness.commit_timeline.length, 2, 'expected bounded commit timeline'); -const latestCommit = timelineHandoff.staleness.commit_timeline[0]; -assert(latestCommit, 'expected at least one commit in timeline'); -assert.strictEqual(latestCommit.subject, 'second change'); -assert(latestCommit.short_sha, 'expected short sha in commit timeline'); - -const projectHandoffFile = path.join(repoDir, PROJECT_HANDOFF_RELATIVE); -fs.mkdirSync(path.dirname(projectHandoffFile), { recursive: true }); -fs.writeFileSync( - projectHandoffFile, - [ - '---', - 'title: Host-written checkpoint', - 'thread_tag: host-sync', - '---', - '# Current state', - '', - 'The host wrote this handoff inside the project directory.', - '', - '## Next', - '', - 'Context Engine should pull it into managed handoffs.', - ].join('\n'), - 'utf8', -); -const synced = await syncProjectHandoff(repoDir); -assert.strictEqual(synced.ok, true, 'expected project handoff file to sync'); -assert.strictEqual(synced.created, true, 'expected first project file sync to create a handoff'); -assert.strictEqual(synced.handoff.thread_tag, 'host-sync'); -assert(synced.handoff.body.includes('Current state'), 'expected synced body from project file'); -fs.writeFileSync( - projectHandoffFile, - [ - '---', - 'title: LLM should not overwrite UI title', - 'thread_tag: host-sync', - '---', - '# Updated by host', - '', - 'Second sync should update the body only.', - ].join('\n'), - 'utf8', -); -const resynced = await syncProjectHandoff(repoDir); -assert.strictEqual(resynced.ok, true, 'expected project handoff file to resync'); -assert.strictEqual(resynced.created, false, 'expected second project file sync to update existing handoff'); -assert.strictEqual( - resynced.handoff.title, - 'Host-written checkpoint', - 'existing UI title should be preserved', -); -assert(resynced.handoff.body.includes('Second sync'), 'expected synced body to update'); - -const appJs = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8'); -const handoffsUi = fs.readFileSync(path.join(__dirname, '..', 'ui', 'handoffs.js'), 'utf8'); -assert(appJs.includes("name === 'handoffs'"), 'switchTab should handle Handoffs activation'); -assert(appJs.includes('HandoffsTab.ensureLoaded'), 'Handoffs tab activation should retry load'); -assert(handoffsUi.includes('ensureLoaded'), 'HandoffsTab should expose an idempotent loader'); -assert(handoffsUi.includes('renderHandoffTimeline'), 'Handoffs detail should render body as timeline cards'); -assert( - handoffsUi.includes('handoff-edit-body'), - 'Handoffs detail should expose a body textarea so users can write the handoff prose themselves', -); -assert( - handoffsUi.includes('handoff-modal-body'), - 'Handoffs create modal should expose a body textarea so the feature is usable end-to-end from the GUI', -); - -const legacySource = path.join(tmpRoot, 'llm-handoff.md'); -fs.writeFileSync( - legacySource, - [ - '# LLM Handoff', - '', - '## Last session', - '', - '**2026-05-12 Current feature** - Continue wiring the new managed surface.', - '', - 'Details stay with the first parsed entry.', - '', - '**2026-05-10 Older work** - Preserve this in archive.', - '', - 'Older detail body.', - '', - '## Open threads', - '', - '- This section is not a dated legacy handoff entry.', - '', - ].join('\n'), - 'utf8', -); -const parsedLegacy = parseLegacyHandoff(fs.readFileSync(legacySource, 'utf8')); -assert.strictEqual(parsedLegacy.length, 2, 'expected two dated legacy entries'); -const firstLegacy = parsedLegacy[0]; -assert(firstLegacy, 'expected first parsed legacy entry'); -assert.strictEqual(firstLegacy.title, 'Current feature'); -assert(firstLegacy.body.includes('Details stay'), 'expected body continuation to stay with entry'); - -const migrated = await migrateLegacyHandoff({ sourceFile: legacySource, repo: tmpRoot, keepActive: 1 }); -assert.strictEqual(migrated.ok, true, 'expected legacy migration to succeed'); -assert.strictEqual(migrated.imported, 2, 'expected two imported legacy entries'); -assert.strictEqual(migrated.active, 1, 'expected newest legacy entry to stay active'); -assert.strictEqual(migrated.archived, 1, 'expected older legacy entry to archive'); -assert(fs.existsSync(path.join(HANDOFFS_DIR, 'legacy-2026-05-12-current-feature.md'))); -assert(fs.existsSync(path.join(ARCHIVE_DIR, 'legacy-2026-05-10-older-work.md'))); - -console.log('handoffs smoke ok'); + git(['commit', '-m', 'baseline']); + const project = createHandoff({ + title: 'Project timeline', + repo: repoDir, + thread_tag: 'project-timeline', + body: 'Track commits made after the handoff was written.', + }); + assert.strictEqual(project.ok, true, 'expected project handoff to create against git repo'); + fs.appendFileSync(path.join(repoDir, 'notes.txt'), 'first\n', 'utf8'); + git(['add', 'notes.txt']); + git(['commit', '-m', 'first change']); + fs.appendFileSync(path.join(repoDir, 'notes.txt'), 'second\n', 'utf8'); + git(['add', 'notes.txt']); + git(['commit', '-m', 'second change']); + const timelineHandoff = listHandoffs().find((handoff) => handoff.slug === 'project-timeline'); + assert(timelineHandoff, 'expected project timeline handoff to stay active under commit threshold'); + assert.strictEqual( + timelineHandoff.staleness.commits_past_head, + 2, + 'expected two commits past handoff head', + ); + + // Restore-doesn't-re-archive regression: archive a project handoff via the + // commit threshold, then restore. The previous bug left head_sha pointing + // at the pre-archive sha so the next listHandoffs() would immediately re- + // archive on the same trip. Restore should refresh head_sha so the counter + // resets and the entry stays active. + const projectArchiveTarget = listHandoffs().find((h) => h.slug === 'project-timeline'); + assert(projectArchiveTarget, 'project handoff should exist before forced archive'); + const archiveResult = await require('../server/lib/handoffs').archiveHandoff('project-timeline'); + assert.strictEqual(archiveResult.ok, true, 'project handoff should archive on demand'); + // Advance the repo so commits_past_head against the old sha is > threshold. + for (let i = 0; i < 6; i++) { + fs.appendFileSync(path.join(repoDir, 'notes.txt'), `extra-${i}\n`, 'utf8'); + git(['add', 'notes.txt']); + git(['commit', '-m', `extra ${i}`]); + } + const restoredProject = await restoreHandoff('project-timeline'); + assert.strictEqual(restoredProject.ok, true, 'archived project handoff should restore'); + // After restore + auto-sweep, the handoff must still be active (i.e. head_sha + // was refreshed; the commit counter is now zero against the new head). + const afterRestoreList = listHandoffs().map((h) => h.slug); + assert( + afterRestoreList.includes('project-timeline'), + 'restored project handoff should stay active — head_sha must be refreshed on restore', + ); + const refreshed = listHandoffs().find((h) => h.slug === 'project-timeline'); + assert(refreshed, 'restored project handoff should appear in active list'); + assert.strictEqual( + refreshed.staleness.commits_past_head, + 0, + 'restored project handoff should report 0 commits past head', + ); + + assert.strictEqual(timelineHandoff.staleness.commit_timeline.length, 2, 'expected bounded commit timeline'); + const latestCommit = timelineHandoff.staleness.commit_timeline[0]; + assert(latestCommit, 'expected at least one commit in timeline'); + assert.strictEqual(latestCommit.subject, 'second change'); + assert(latestCommit.short_sha, 'expected short sha in commit timeline'); + + const projectHandoffFile = path.join(repoDir, PROJECT_HANDOFF_RELATIVE); + fs.mkdirSync(path.dirname(projectHandoffFile), { recursive: true }); + fs.writeFileSync( + projectHandoffFile, + [ + '---', + 'title: Host-written checkpoint', + 'thread_tag: host-sync', + '---', + '# Current state', + '', + 'The host wrote this handoff inside the project directory.', + '', + '## Next', + '', + 'Context Engine should pull it into managed handoffs.', + ].join('\n'), + 'utf8', + ); + const synced = await syncProjectHandoff(repoDir); + assert.strictEqual(synced.ok, true, 'expected project handoff file to sync'); + assert.strictEqual(synced.created, true, 'expected first project file sync to create a handoff'); + assert.strictEqual(synced.handoff.thread_tag, 'host-sync'); + assert(synced.handoff.body.includes('Current state'), 'expected synced body from project file'); + fs.writeFileSync( + projectHandoffFile, + [ + '---', + 'title: LLM should not overwrite UI title', + 'thread_tag: host-sync', + '---', + '# Updated by host', + '', + 'Second sync should update the body only.', + ].join('\n'), + 'utf8', + ); + const resynced = await syncProjectHandoff(repoDir); + assert.strictEqual(resynced.ok, true, 'expected project handoff file to resync'); + assert.strictEqual(resynced.created, false, 'expected second project file sync to update existing handoff'); + assert.strictEqual( + resynced.handoff.title, + 'Host-written checkpoint', + 'existing UI title should be preserved', + ); + assert(resynced.handoff.body.includes('Second sync'), 'expected synced body to update'); + + const appJs = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8'); + const handoffsUi = fs.readFileSync(path.join(__dirname, '..', 'ui', 'handoffs.js'), 'utf8'); + assert(appJs.includes("name === 'handoffs'"), 'switchTab should handle Handoffs activation'); + assert(appJs.includes('HandoffsTab.ensureLoaded'), 'Handoffs tab activation should retry load'); + assert(handoffsUi.includes('ensureLoaded'), 'HandoffsTab should expose an idempotent loader'); + assert( + handoffsUi.includes('renderHandoffTimeline'), + 'Handoffs detail should render body as timeline cards', + ); + assert( + handoffsUi.includes('handoff-edit-body'), + 'Handoffs detail should expose a body textarea so users can write the handoff prose themselves', + ); + assert( + handoffsUi.includes('handoff-modal-body'), + 'Handoffs create modal should expose a body textarea so the feature is usable end-to-end from the GUI', + ); + + // ---- getHandoff ---- + + // GIVEN an existing active handoff + const foundActive = getHandoff('project-timeline'); + assert.ok(foundActive, 'getHandoff returns handoff for existing slug'); + assert.strictEqual(foundActive.slug, 'project-timeline', 'getHandoff returns correct slug'); + + // GIVEN a non-existent handoff + const notFound = getHandoff('no-such-handoff'); + assert.strictEqual(notFound, null, 'getHandoff returns null for unknown slug'); + + // GIVEN a path-traversal slug + const traversal = getHandoff('../../etc/passwd'); + assert.strictEqual(traversal, null, 'getHandoff returns null for path-traversal slug'); + + // ---- updateHandoff ---- + + // GIVEN an active handoff + createHandoff({ title: 'Update Target', thread_tag: 'update-target', body: 'Original body.' }); + // WHEN we update the title + const titleUpdate = await updateHandoff('update-target', { title: 'Updated Title' }); + assert.strictEqual(titleUpdate.ok, true, 'updateHandoff succeeds for title'); + assert.strictEqual(titleUpdate.handoff.title, 'Updated Title', 'title is updated'); + // AND slug does not change + assert.strictEqual(titleUpdate.handoff.slug, 'update-target', 'slug stays same after title update'); + + // WHEN we update the body + const bodyUpdate = await updateHandoff('update-target', { body: 'New body content.' }); + assert.strictEqual(bodyUpdate.ok, true, 'updateHandoff succeeds for body'); + assert.ok(bodyUpdate.handoff.body.includes('New body content'), 'body is updated'); + + // WHEN we try to update a non-existent handoff + const updateMiss = await updateHandoff('nonexistent-slug', { title: 'X' }); + assert.strictEqual(updateMiss.ok, false, 'updateHandoff fails for non-existent slug'); + + // WHEN we try to update with path-traversal slug + const updateTraversal = await updateHandoff('../../etc/passwd', { title: 'X' }); + assert.strictEqual(updateTraversal.ok, false, 'updateHandoff rejects path-traversal slug'); + + // ---- createHandoff slug collision ---- + + createHandoff({ title: 'Collision Test', thread_tag: 'collision-test', body: 'First.' }); + const collision = createHandoff({ + title: 'Collision Test', + thread_tag: 'collision-test-2', + body: 'Second.', + }); + assert.strictEqual(collision.ok, true, 'createHandoff succeeds when slug collides'); + assert.ok(collision.handoff.slug.startsWith('collision-test'), 'collision slug has prefix'); + assert.notStrictEqual(collision.handoff.slug, 'collision-test', 'collision slug has suffix'); + + // ---- createHandoff without title ---- + + const noTitle = createHandoff({ title: '', body: 'No title.' }); + assert.strictEqual(noTitle.ok, false, 'createHandoff fails with empty title'); + assert.strictEqual(noTitle.error, 'title is required', 'error mentions title'); + + // ---- createHandoff with non-directory repo ---- + + const badRepo = createHandoff({ title: 'Bad Repo', repo: '/nonexistent/path/xyz', body: 'Bad repo.' }); + assert.strictEqual(badRepo.ok, false, 'createHandoff fails with nonexistent repo path'); + + const legacySource = path.join(tmpRoot, 'llm-handoff.md'); + fs.writeFileSync( + legacySource, + [ + '# LLM Handoff', + '', + '## Last session', + '', + '**2026-05-12 Current feature** - Continue wiring the new managed surface.', + '', + 'Details stay with the first parsed entry.', + '', + '**2026-05-10 Older work** - Preserve this in archive.', + '', + 'Older detail body.', + '', + '## Open threads', + '', + '- This section is not a dated legacy handoff entry.', + '', + ].join('\n'), + 'utf8', + ); + const parsedLegacy = parseLegacyHandoff(fs.readFileSync(legacySource, 'utf8')); + assert.strictEqual(parsedLegacy.length, 2, 'expected two dated legacy entries'); + const firstLegacy = parsedLegacy[0]; + assert(firstLegacy, 'expected first parsed legacy entry'); + assert.strictEqual(firstLegacy.title, 'Current feature'); + assert(firstLegacy.body.includes('Details stay'), 'expected body continuation to stay with entry'); + + const migrated = await migrateLegacyHandoff({ sourceFile: legacySource, repo: tmpRoot, keepActive: 1 }); + assert.strictEqual(migrated.ok, true, 'expected legacy migration to succeed'); + assert.strictEqual(migrated.imported, 2, 'expected two imported legacy entries'); + assert.strictEqual(migrated.active, 1, 'expected newest legacy entry to stay active'); + assert.strictEqual(migrated.archived, 1, 'expected older legacy entry to archive'); + assert(fs.existsSync(path.join(HANDOFFS_DIR, 'current-feature.md'))); + assert(fs.existsSync(path.join(ARCHIVE_DIR, 'older-work.md'))); + + console.log('handoffs smoke ok'); })().catch((err) => { console.error(err); process.exit(1); diff --git a/scripts/mutex-smoke.js b/scripts/mutex-smoke.js new file mode 100644 index 0000000..5fa6058 --- /dev/null +++ b/scripts/mutex-smoke.js @@ -0,0 +1,109 @@ +// @ts-check + +// mutex-smoke.js — Smoke test for per-key mutex concurrency + +const assert = require('assert'); +const { createKeyMutex } = require('../server/lib/per-key-mutex'); + +void (async () => { + // ---- serial execution per key ---- + + // GIVEN a mutex + const mutex = createKeyMutex(); + + // WHEN two operations run on the same key + const order = /** @type {string[]} */ ([]); + let p1Done = false; + const p1 = mutex('key-a', () => { + /** @type {Promise} */ + const promise = new Promise((resolve) => + setTimeout(() => { + order.push('p1'); + p1Done = true; + resolve(); + }, 50), + ); + return promise; + }); + // eslint-disable-next-line @typescript-eslint/require-await + const p2 = mutex('key-a', async () => { + assert.ok(p1Done, 'p2 must wait for p1'); + order.push('p2'); + }); + await p1; + await p2; + assert.deepStrictEqual(order, ['p1', 'p2'], 'same-key operations are serialized'); + + // ---- parallel execution across keys ---- + + // WHEN two operations run on different keys + const parallelOrder = /** @type {string[]} */ ([]); + let aStarted = false; + let bStarted = false; + const pa = mutex('key-x', () => { + /** @type {Promise} */ + const promise = new Promise((resolve) => + setTimeout(() => { + aStarted = true; + parallelOrder.push('a'); + resolve(); + }, 30), + ); + return promise; + }); + const pb = mutex('key-y', () => { + /** @type {Promise} */ + const promise = new Promise((resolve) => + setTimeout(() => { + bStarted = true; + parallelOrder.push('b'); + resolve(); + }, 10), + ); + return promise; + }); + await Promise.all([pa, pb]); + assert.ok(aStarted && bStarted, 'different keys run in parallel'); + assert.strictEqual(parallelOrder[0], 'b', 'shorter task on different key finishes first'); + + // ---- predecessor error doesn't block successor ---- + + // GIVEN a mutex + const errMutex = createKeyMutex(); + + // WHEN the first operation on a key throws + let successorRan = false; + const failing = errMutex('err-key', () => Promise.reject(new Error('intentional'))); + try { + await failing; + } catch { + // expected + } + // eslint-disable-next-line @typescript-eslint/require-await + const succeeding = errMutex('err-key', async () => { + successorRan = true; + }); + await succeeding; + assert.ok(successorRan, 'successor runs even after predecessor error'); + + // ---- return value preserved ---- + + const returnMutex = createKeyMutex(); + const result = await returnMutex('ret-key', () => Promise.resolve(42)); + assert.strictEqual(result, 42, 'return value is preserved'); + + // ---- cleanup after completion ---- + + // WHEN an operation completes + const cleanMutex = createKeyMutex(); + await cleanMutex('clean-key', () => Promise.resolve()); + // AND we start a new operation on the same key + let secondRan = false; + // eslint-disable-next-line @typescript-eslint/require-await + await cleanMutex('clean-key', async () => { + secondRan = true; + }); + assert.ok(secondRan, 'key is reusable after completion'); + + console.log('mutex smoke ok'); +})(); \ No newline at end of file diff --git a/scripts/projects-smoke.js b/scripts/projects-smoke.js new file mode 100644 index 0000000..61a542c --- /dev/null +++ b/scripts/projects-smoke.js @@ -0,0 +1,368 @@ +// @ts-check + +// projects-smoke.js — Self-contained smoke test for project CRUD + +const fs = require('fs'); +const http = require('http'); +const os = require('os'); +const path = require('path'); + +// GIVEN a fresh CE_ROOT so we test in isolation +const testRoot = path.join(os.tmpdir(), 'ce-projects-test-' + Date.now()); +fs.mkdirSync(path.join(testRoot, 'data'), { recursive: true }); +process.env.CE_ROOT = testRoot; + +let pass = 0; +let fail = 0; + +/** @param {boolean} cond @param {string} label */ +function check(cond, label) { + if (cond) { + pass++; + console.log(` PASS: ${label}`); + } else { + fail++; + console.log(` FAIL: ${label}`); + } +} + +// =================================================================== +// SECTION 1: Library-level unit tests +// =================================================================== + +// Clear require cache so modules pick up our CE_ROOT +delete require.cache[require.resolve('../server/lib/config')]; +delete require.cache[require.resolve('../server/lib/projects')]; + +const projects = require('../server/lib/projects'); + +// ---- listProjects ---- + +// GIVEN no projects exist yet +check(projects.listProjects().length === 0, 'listProjects returns empty array when no projects'); + +// GIVEN a registry where projects key exists but is not an array +fs.writeFileSync(projects.PROJECTS_FILE, JSON.stringify({ version: '1.0', projects: 'not-array' }), 'utf8'); +const nonArrayResult = projects.listProjects(); +check(Array.isArray(nonArrayResult), 'listProjects returns array when projects key is not array'); +check(nonArrayResult.length === 0, 'listProjects returns empty array when projects key is not array'); + +// GIVEN a corrupted projects.json +fs.writeFileSync(projects.PROJECTS_FILE, 'NOT JSON', 'utf8'); +const corruptResult = projects.listProjects(); +check(Array.isArray(corruptResult), 'listProjects returns array when registry file is corrupt'); +check(corruptResult.length === 0, 'listProjects returns empty array when registry file is corrupt'); + +// GIVEN projects.json doesn't exist at all +fs.rmSync(path.join(testRoot, 'data'), { recursive: true, force: true }); +fs.mkdirSync(path.join(testRoot, 'data'), { recursive: true }); +const missingResult = projects.listProjects(); +check(Array.isArray(missingResult), 'listProjects returns array when registry file is missing'); +check(missingResult.length === 0, 'listProjects returns empty array when registry file is missing'); + +// ---- uniqueSlug (tested via createProject) ---- + +// WHEN we create a project with name only +const r1 = projects.createProject({ name: 'My App' }); +check(r1.ok === true, 'createProject succeeds with name only'); +const p1 = + /** @type {{ slug: string, created: string, last_touched: string, path: string | undefined, name: string }} */ ( + r1.project + ); +check(p1.slug === 'my-app', 'slug derives from name (spaces to hyphens)'); +check(p1.name === 'My App', 'project stores original name'); +check(typeof p1.created === 'string', 'project has created timestamp'); +check(typeof p1.last_touched === 'string', 'project has last_touched timestamp'); +check(p1.path === undefined, 'path is undefined when not provided'); + +// WHEN name has leading/trailing special chars +const rSpecial = projects.createProject({ name: '---Hello!!!World---' }); +check(rSpecial.ok === true, 'createProject succeeds with special chars in name'); +check( + /** @type {{ slug: string }} */ (rSpecial.project).slug === 'hello-world', + 'slug strips leading/trailing hyphens and special chars', +); + +// WHEN name is all special chars (slug falls back to "project") +const rAllSpecial = projects.createProject({ name: '!!!@@@###' }); +check(rAllSpecial.ok === true, 'createProject succeeds with all-special-char name'); +check( + /** @type {{ slug: string }} */ (rAllSpecial.project).slug === 'project', + 'slug falls back to "project" when all chars are stripped', +); + +// WHEN name is whitespace only +const rWhitespace = projects.createProject({ name: ' ' }); +check(rWhitespace.ok === false, 'createProject fails with whitespace-only name'); +check(rWhitespace.error === 'name is required', 'whitespace-only name gives "name is required" error'); + +// WHEN name is very long (slug truncates to 60 chars) +const longName = 'A'.repeat(100); +const rLong = projects.createProject({ name: longName }); +check(rLong.ok === true, 'createProject succeeds with very long name'); +check( + /** @type {{ slug: string }} */ (rLong.project).slug.length <= 60, + 'slug is truncated to at most 60 chars', +); + +// ---- createProject directory structure ---- + +const pDir = path.join(projects.PROJECTS_DIR, p1.slug); +check(fs.existsSync(pDir), 'project directory is created'); +check(fs.existsSync(path.join(pDir, 'handoffs')), 'handoffs subdirectory is created'); +check(fs.existsSync(path.join(pDir, 'memory.json')), 'memory.json seed file is created'); +check(fs.existsSync(path.join(pDir, 'rules.json')), 'rules.json seed file is created'); + +// AND the seed files are valid JSON +const memData = JSON.parse(fs.readFileSync(path.join(pDir, 'memory.json'), 'utf8')); +check(memData.version === '1.1', 'memory.json has correct version'); +check(Array.isArray(memData.entries), 'memory.json has entries array'); +const rulesData = JSON.parse(fs.readFileSync(path.join(pDir, 'rules.json'), 'utf8')); +check('coding' in rulesData && 'general' in rulesData && 'soul' in rulesData, 'rules.json has expected keys'); + +// WHEN we create a project with name and path +const r2 = projects.createProject({ name: 'Has Path', path: 'C:\\dev\\has-path' }); +check(r2.ok === true, 'createProject succeeds with name and path'); +const p2 = /** @type {{ slug: string, path: string }} */ (r2.project); +check(p2.path === 'C:\\dev\\has-path', 'project stores the path'); +check(p2.slug === 'has-path', 'slug derives from name with path provided'); + +// WHEN we create a project with whitespace-padded path +const rPadPath = projects.createProject({ name: 'Padded', path: ' C:\\dev\\pad ' }); +check(rPadPath.ok === true, 'createProject succeeds with whitespace-padded path'); +check(/** @type {{ path: string }} */ (rPadPath.project).path === 'C:\\dev\\pad', 'path is trimmed'); + +// WHEN we create a project with empty path +const rEmptyPath = projects.createProject({ name: 'Empty Path', path: '' }); +check(rEmptyPath.ok === true, 'createProject succeeds with empty path'); +check( + /** @type {{ path: string | undefined }} */ (rEmptyPath.project).path === undefined, + 'empty path becomes undefined', +); + +// WHEN we create a project without a name +const rNoName = projects.createProject({ name: '' }); +check(rNoName.ok === false, 'createProject fails with empty name'); +check(rNoName.error === 'name is required', 'error message is "name is required"'); + +// WHEN we create a project with no input at all +const rNoInput = projects.createProject({}); +check(rNoInput.ok === false, 'createProject fails with no input'); + +// ---- slug collision ---- + +// GIVEN a project named "duplicate" already exists +projects.createProject({ name: 'duplicate' }); + +// WHEN we create another project with the same name +const rDup = projects.createProject({ name: 'duplicate' }); +check(rDup.ok === true, 'createProject succeeds when slug collides'); +check( + /** @type {{ slug: string }} */ (rDup.project).slug === 'duplicate-2', + 'slug gets -2 suffix on collision', +); + +// WHEN we create a third duplicate +const rDup3 = projects.createProject({ name: 'duplicate' }); +check(rDup3.ok === true, 'createProject succeeds on third collision'); +check( + /** @type {{ slug: string }} */ (rDup3.project).slug === 'duplicate-3', + 'slug gets -3 suffix on third collision', +); + +// WHEN the "project" fallback slug already exists (created above via '!!!@@@###') +const rFallbackDup = projects.createProject({ name: '@$$%' }); +check(rFallbackDup.ok === true, 'fallback slug "project" gets collision suffix'); +const fallbackSlug = /** @type {{ slug: string }} */ (rFallbackDup.project).slug; +check(fallbackSlug.startsWith('project-'), 'fallback slug collision produces "project-N"'); + +// ---- getProject ---- + +const found = projects.getProject('my-app'); +check(found !== null, 'getProject returns project when slug exists'); +check(found && found.name === 'My App', 'getProject returns correct project name'); + +check(projects.getProject('no-such-slug') === null, 'getProject returns null for unknown slug'); + +// ---- updateProject ---- + +const uName = projects.updateProject('my-app', { name: 'My Updated App' }); +check(uName.ok === true, 'updateProject succeeds for name'); +check( + /** @type {{ name: string, slug: string }} */ (uName.project).name === 'My Updated App', + 'name is updated', +); +check( + /** @type {{ name: string, slug: string }} */ (uName.project).slug === 'my-app', + 'slug stays the same after name update', +); + +const uPath = projects.updateProject('my-app', { path: 'C:\\new\\path' }); +check(uPath.ok === true, 'updateProject succeeds for path'); +check(/** @type {{ path: string }} */ (uPath.project).path === 'C:\\new\\path', 'path is updated'); + +const uClear = projects.updateProject('my-app', { path: '' }); +check(uClear.ok === true, 'updateProject succeeds for clearing path'); +check( + /** @type {{ path: string | undefined }} */ (uClear.project).path === undefined, + 'path becomes undefined when cleared', +); + +const beforeTouch = projects.getProject('my-app')?.last_touched; +const uTouch = projects.updateProject('my-app', { name: 'My Updated App' }); +check(uTouch.ok === true, 'updateProject succeeds with same name'); +check( + /** @type {{ last_touched: string }} */ (uTouch.project).last_touched !== beforeTouch, + 'last_touched changes on update', +); + +// WHEN we update with empty patch (no name, no path) +const uEmpty = projects.updateProject('my-app', {}); +check(uEmpty.ok === true, 'updateProject succeeds with empty patch'); +const uEmptyLastTouched = /** @type {{ last_touched: string }} */ (uEmpty.project).last_touched; +check(uEmptyLastTouched !== beforeTouch, 'last_touched changes even with empty patch'); + +const uMiss = projects.updateProject('no-such-slug', { name: 'X' }); +check(uMiss.ok === false, 'updateProject fails for non-existent slug'); +check(uMiss.error === 'Project not found', 'error message is "Project not found"'); + +// ---- deleteProject ---- + +const d1 = projects.deleteProject('has-path'); +check(d1.ok === true, 'deleteProject succeeds for existing project'); +check(!fs.existsSync(path.join(projects.PROJECTS_DIR, 'has-path')), 'project directory is removed on delete'); +check( + projects.listProjects().find(/** @param {{ slug: string }} p */ (p) => p.slug === 'has-path') === undefined, + 'deleted project no longer in listing', +); + +const dMiss = projects.deleteProject('no-such-slug'); +check(dMiss.ok === false, 'deleteProject fails for non-existent slug'); +check(dMiss.error === 'Project not found', 'delete error is "Project not found"'); + +// WHEN we delete a project whose directory was already removed +const rAlreadyGone = projects.createProject({ name: 'Already Gone' }); +const slugAlreadyGone = /** @type {{ slug: string }} */ (rAlreadyGone.project).slug; +fs.rmSync(path.join(projects.PROJECTS_DIR, slugAlreadyGone), { recursive: true, force: true }); +const dAlreadyGone = projects.deleteProject(slugAlreadyGone); +check(dAlreadyGone.ok === true, 'deleteProject succeeds even when directory already removed'); + +// =================================================================== +// SECTION 2: HTTP-level integration tests +// =================================================================== + +// Re-clear require cache for config so the server picks up CE_ROOT +delete require.cache[require.resolve('../server/lib/config')]; +delete require.cache[require.resolve('../server/lib/backup')]; +delete require.cache[require.resolve('../server/lib/projects')]; + +const { startServer } = require('../server/server'); + +const serverPort = 3857; +process.env.CE_PORT = String(serverPort); + +/** + * @param {string} method + * @param {string} urlPath + * @param {Record} [body] + * @returns {Promise<{ status: number | undefined, data: unknown }>} + */ +function api(method, urlPath, body) { + return new Promise((resolve, reject) => { + const opts = { + hostname: 'localhost', + port: serverPort, + path: urlPath, + method, + headers: { 'Content-Type': 'application/json' }, + }; + const req = http.request(opts, (res) => { + let d = ''; + res.on('data', (c) => (d += c)); + res.on('end', () => { + try { + resolve({ status: res.statusCode, data: JSON.parse(d) }); + } catch { + resolve({ status: res.statusCode, data: d }); + } + }); + }); + req.on('error', reject); + if (body) req.write(JSON.stringify(body)); + req.end(); + }); +} + +async function runHttpTests() { + const server = startServer({ port: serverPort, refresh: false }); + try { + await new Promise((resolve) => server.once('listening', resolve)); + + // GET /api/projects + const getList = await api('GET', '/api/projects'); + check(getList.status === 200, 'HTTP GET /api/projects returns 200'); + check( + Array.isArray(/** @type {any} */ (getList.data).projects), + 'HTTP GET /api/projects returns projects array', + ); + + // POST /api/projects with valid data + const postOk = await api('POST', '/api/projects', { name: 'HTTP Project' }); + check(postOk.status === 200, 'HTTP POST /api/projects with valid data returns 200'); + check(/** @type {any} */ (postOk.data).ok === true, 'HTTP POST /api/projects returns ok'); + + const httpSlug = /** @type {any} */ (postOk.data).project?.slug; + check(typeof httpSlug === 'string', 'HTTP POST /api/projects returns project with slug'); + + // POST /api/projects with empty name + const postFail = await api('POST', '/api/projects', { name: '' }); + check(postFail.status === 400, 'HTTP POST /api/projects with empty name returns 400'); + + // PATCH /api/projects/:slug + const patchOk = await api('PATCH', `/api/projects/${encodeURIComponent(httpSlug)}`, { + name: 'HTTP Updated', + }); + check(patchOk.status === 200, 'HTTP PATCH /api/projects/:slug returns 200'); + check( + /** @type {any} */ (patchOk.data).project?.name === 'HTTP Updated', + 'HTTP PATCH updates project name', + ); + + // PATCH /api/projects/:slug with non-existent slug + const patchMiss = await api('PATCH', '/api/projects/no-such', { name: 'X' }); + check(patchMiss.status === 404, 'HTTP PATCH non-existent project returns 404'); + + // DELETE /api/projects/:slug + const deleteOk = await api('DELETE', `/api/projects/${encodeURIComponent(httpSlug)}`); + check(deleteOk.status === 200, 'HTTP DELETE /api/projects/:slug returns 200'); + check(/** @type {any} */ (deleteOk.data).ok === true, 'HTTP DELETE returns ok'); + + // DELETE /api/projects/:slug with non-existent slug + const deleteMiss = await api('DELETE', '/api/projects/no-such'); + check(deleteMiss.status === 404, 'HTTP DELETE non-existent project returns 404'); + } finally { + server.close(); + } +} + +// =================================================================== +// Run +// =================================================================== + +void (async () => { + try { + await runHttpTests(); + } catch (e) { + console.error('HTTP tests failed:', e); + } + + // ---- cleanup ---- + try { + fs.rmSync(testRoot, { recursive: true, force: true }); + } catch { + // ignore + } + + console.log(`\n${pass} passed, ${fail} failed`); + process.exit(fail ? 1 : 0); +})(); diff --git a/scripts/ranking-smoke.js b/scripts/ranking-smoke.js new file mode 100644 index 0000000..de22cb6 --- /dev/null +++ b/scripts/ranking-smoke.js @@ -0,0 +1,105 @@ +// @ts-check + +// ranking-smoke.js — Smoke test for dedup ranking/scoring + +const assert = require('assert'); +const { chooseKeeper, scoreChunk, scoreSkills, tokenize } = require('../server/lib/ranking'); + +// ---- tokenize ---- + +// GIVEN a simple sentence +// WHEN tokenized +const tokens = tokenize('Use TypeScript for safer refactors'); +assert.ok( + tokens.some((t) => t === 'typescript'), + 'lowercases tokens', +); +assert.ok( + tokens.some((t) => t === 'refactors'), + 'extracts individual words', +); + +// GIVEN text with backtick code blocks +const codeTokens = tokenize('Set `foo.bar = 1` in the config'); +assert.ok(!codeTokens.some((t) => t === 'foo.bar'), 'code inside backticks is removed'); +assert.ok( + codeTokens.some((t) => t === 'config'), + 'non-code text is preserved', +); + +// GIVEN empty text +const emptyTokens = tokenize(''); +assert.deepStrictEqual(emptyTokens, [], 'empty text produces empty tokens'); + +// GIVEN text with only short words +const shortTokens = tokenize('a b c d'); +assert.deepStrictEqual(shortTokens, [], 'words under 3 chars are filtered'); + +// ---- scoreChunk ---- + +/** @type {import('../server/lib/vectorstore').VectorRecord} */ +const ruleChunk = { + id: 'test:1', + skillId: 'test-skill', + section: 'Rules', + text: 'Always use TypeScript strict mode for safer refactoring workflows', + type: 'rule', + sourcePath: 'test-skill/SKILL.md', + vector: [0.1], +}; +const ruleScore = scoreChunk(ruleChunk); +assert.strictEqual(typeof ruleScore.total, 'number', 'scoreChunk returns total'); +assert.ok(ruleScore.total >= 0 && ruleScore.total <= 1, 'total is between 0 and 1'); +assert.strictEqual(typeof ruleScore.specificity, 'number', 'scoreChunk returns specificity'); +assert.strictEqual(typeof ruleScore.coverage, 'number', 'scoreChunk returns coverage'); +assert.strictEqual(typeof ruleScore.sourceWeight, 'number', 'scoreChunk returns sourceWeight'); +assert.strictEqual(typeof ruleScore.freshness, 'number', 'scoreChunk returns freshness'); + +/** @type {import('../server/lib/vectorstore').VectorRecord} */ +const exampleChunk = { ...ruleChunk, id: 'test:2', type: 'example', section: 'Example', vector: [0.1] }; +const exampleScore = scoreChunk(exampleChunk); +assert.ok(exampleScore.total < ruleScore.total, 'example score is lower than rule score'); + +/** @type {import('../server/lib/vectorstore').VectorRecord} */ +const knowledgeChunk = { ...ruleChunk, id: 'test:3', type: 'knowledge', section: 'Overview', vector: [0.1] }; +const knowledgeScore = scoreChunk(knowledgeChunk); +assert.ok(knowledgeScore.total < ruleScore.total, 'knowledge score is lower than rule score'); + +// ---- scoreSkills ---- + +/** @type {import('../server/lib/vectorstore').VectorRecord[]} */ +const records = [ + { ...ruleChunk, id: 'a:1', skillId: 'skill-a', vector: [0.1] }, + { + ...ruleChunk, + id: 'a:2', + skillId: 'skill-a', + text: 'Different workflow implementation process', + vector: [0.1], + }, + { ...ruleChunk, id: 'b:1', skillId: 'skill-b', type: 'knowledge', vector: [0.1] }, +]; +const skillScores = scoreSkills(records); +assert.strictEqual(typeof skillScores['skill-a'], 'number', 'skill-a has a score'); +assert.strictEqual(typeof skillScores['skill-b'], 'number', 'skill-b has a score'); +const aScore = /** @type {number} */ (skillScores['skill-a']); +const bScore = /** @type {number} */ (skillScores['skill-b']); +assert.ok(aScore > bScore, 'skill with rule records scores higher'); + +// GIVEN empty records +const emptyScores = scoreSkills(/** @type {import('../server/lib/vectorstore').VectorRecord[]} */ ([])); +assert.deepStrictEqual(emptyScores, {}, 'empty records produce empty scores'); + +// ---- chooseKeeper ---- + +const keeper = chooseKeeper(records); +assert.strictEqual(keeper, 'skill-a', 'chooseKeeper picks highest-scoring skill'); + +// GIVEN empty records +assert.strictEqual( + chooseKeeper(/** @type {import('../server/lib/vectorstore').VectorRecord[]} */ ([])), + null, + 'chooseKeeper returns null for empty records', +); + +console.log('ranking smoke ok'); diff --git a/scripts/security-smoke.js b/scripts/security-smoke.js new file mode 100644 index 0000000..937a582 --- /dev/null +++ b/scripts/security-smoke.js @@ -0,0 +1,130 @@ +// @ts-check + +// security-smoke.js — Smoke test for request validation and write-path protection + +const assert = require('assert'); +const path = require('path'); + +const { isLocalRequest, checkSafeWritePath } = require('../server/lib/security'); + +/** @type {(headers: Record) => import('http').IncomingMessage} */ +function mockReq(headers) { + return /** @type {import('http').IncomingMessage} */ ({ headers }); +} + +// ---- isLocalRequest ---- + +// GIVEN a request with loopback Host header +// WHEN isLocalRequest checks it +assert.strictEqual( + isLocalRequest(mockReq({ host: '127.0.0.1:3847' }), 3847), + true, + '127.0.0.1 host is local', +); +assert.strictEqual( + isLocalRequest(mockReq({ host: 'localhost:3847' }), 3847), + true, + 'localhost host is local', +); +assert.strictEqual( + isLocalRequest(mockReq({ host: '[::1]:3847' }), 3847), + true, + 'IPv6 loopback host is local', +); + +// GIVEN a request with external Host header +assert.strictEqual( + isLocalRequest(mockReq({ host: 'evil.example.com' }), 3847), + false, + 'external hostname is not local', +); +assert.strictEqual(isLocalRequest(mockReq({ host: '192.168.1.1:3847' }), 3847), false, 'LAN IP is not local'); + +// GIVEN a request with no Host header +assert.strictEqual(isLocalRequest(mockReq({}), 3847), false, 'missing host header is not local'); + +// GIVEN a request with loopback Origin header and loopback Host +assert.strictEqual( + isLocalRequest(mockReq({ host: '127.0.0.1', origin: 'http://localhost:3847' }), 3847), + true, + 'loopback origin + loopback host is local', +); + +// GIVEN a request with external Origin header and loopback Host (CSRF) +assert.strictEqual( + isLocalRequest(mockReq({ host: '127.0.0.1', origin: 'https://evil.example.com' }), 3847), + false, + 'external origin with loopback host is CSRF', +); + +// GIVEN a request with malformed Origin header +assert.strictEqual( + isLocalRequest(mockReq({ host: '127.0.0.1', origin: 'not-a-url' }), 3847), + false, + 'malformed origin is rejected', +); + +// ---- checkSafeWritePath ---- + +// GIVEN a normal project directory +assert.strictEqual( + checkSafeWritePath(path.join(process.cwd(), 'my-project')), + null, + 'normal project path is safe', +); + +// GIVEN no path +assert.strictEqual(checkSafeWritePath(''), 'path is required', 'empty path is rejected'); +assert.strictEqual( + checkSafeWritePath(/** @type {string} */ (/** @type {unknown} */ (null))), + 'path is required', + 'null path is rejected', +); + +// GIVEN Windows system directories +const sysResult = checkSafeWritePath('C:\\Windows\\System32\\drivers'); +assert.ok(sysResult !== null, 'Windows system dir is blocked'); +assert.ok( + sysResult && sysResult.includes('Refusing to write into protected location'), + 'error mentions protected location', +); + +const pfResult = checkSafeWritePath('C:\\Program Files\\MyApp'); +assert.ok(pfResult !== null, 'Program Files is blocked'); + +// GIVEN SSH directory +const homeSsh = path.join(require('os').homedir(), '.ssh'); +assert.strictEqual( + checkSafeWritePath(homeSsh), + 'Refusing to write into protected location: ' + homeSsh, + 'user .ssh dir is blocked', +); + +// GIVEN a path with a protected fragment +assert.strictEqual( + checkSafeWritePath(path.join(process.cwd(), 'my-project', '.ssh', 'keys')), + 'Refusing to write into protected directory segment: .ssh', + '.ssh fragment in path is blocked', +); +assert.strictEqual( + checkSafeWritePath(path.join(process.cwd(), 'my-project', '.aws', 'config')), + 'Refusing to write into protected directory segment: .aws', + '.aws fragment in path is blocked', +); + +// GIVEN a UNC path +assert.strictEqual( + checkSafeWritePath('\\\\server\\share\\path'), + 'Refusing to use UNC / network-share paths', + 'UNC path is blocked', +); + +// GIVEN a path that starts with protected but is different (e.g. ".sshconfig") +const safeButSimilar = path.join(process.cwd(), 'sshconfig-project'); +assert.strictEqual( + checkSafeWritePath(safeButSimilar), + null, + 'path containing ssh as substring but not segment is safe', +); + +console.log('security smoke ok'); diff --git a/scripts/validation-smoke.js b/scripts/validation-smoke.js new file mode 100644 index 0000000..57318c6 --- /dev/null +++ b/scripts/validation-smoke.js @@ -0,0 +1,102 @@ +// @ts-check + +// validation-smoke.js — Smoke test for request body validators + +const assert = require('assert'); +const { validateMemory, validateRules, validateStates } = require('../server/lib/validation'); + +// ---- validateMemory ---- + +// GIVEN valid memory data +// WHEN validated +const memOk = validateMemory({ entries: [{ content: 'User prefers dark mode' }] }); +assert.strictEqual(memOk.valid, true, 'valid memory passes'); +assert.strictEqual(memOk.error, null, 'valid memory has no error'); + +// GIVEN missing entries array +const memNoEntries = validateMemory({}); +assert.strictEqual(memNoEntries.valid, false, 'missing entries fails'); +assert.strictEqual(memNoEntries.error, 'Missing or invalid "entries" array'); + +// GIVEN entries not an array +const memBadEntries = validateMemory({ entries: 'not-array' }); +assert.strictEqual(memBadEntries.valid, false, 'entries as string fails'); + +// GIVEN entry without content +const memNoContent = validateMemory({ entries: [{ content: '' }] }); +assert.strictEqual(memNoContent.valid, false, 'empty content fails'); +assert.ok( + memNoContent.error && memNoContent.error.includes('missing "content" string'), + 'error mentions content', +); + +// GIVEN entry with whitespace-only content +const memWhitespace = validateMemory({ entries: [{ content: ' ' }] }); +assert.strictEqual(memWhitespace.valid, false, 'whitespace-only content fails'); + +// GIVEN entry that is not an object +const memBadEntry = validateMemory({ entries: ['string-entry'] }); +assert.strictEqual(memBadEntry.valid, false, 'string entry fails'); +assert.ok(memBadEntry.error && memBadEntry.error.includes('must be an object'), 'error mentions object'); + +// GIVEN null input +const memNull = validateMemory(null); +assert.strictEqual(memNull.valid, false, 'null input fails'); +assert.strictEqual(memNull.error, 'Must be a JSON object'); + +// GIVEN parse error marker +const memParseError = validateMemory({ _parseError: true }); +assert.strictEqual(memParseError.valid, false, 'parse error marker fails'); + +// GIVEN valid entry at index boundary +const memIdx = validateMemory({ entries: [null, { content: 'ok' }] }); +assert.strictEqual(memIdx.valid, false, 'null entry at index 0 fails'); +assert.ok(memIdx.error && memIdx.error.includes('Entry 0'), 'error references correct index'); + +// ---- validateRules ---- + +// GIVEN valid rules data +const rulesOk = validateRules({ coding: 'Use strict mode', general: 'Be helpful', soul: 'Curious' }); +assert.strictEqual(rulesOk.valid, true, 'valid rules passes'); + +// GIVEN missing one of the required keys +const rulesMissing = validateRules({ coding: 'x', general: 'y' }); +assert.strictEqual(rulesMissing.valid, false, 'missing soul key fails'); +assert.ok(rulesMissing.error && rulesMissing.error.includes('soul'), 'error mentions missing key'); + +// GIVEN key with wrong type +const rulesWrongType = validateRules({ coding: 42, general: '', soul: '' }); +assert.strictEqual(rulesWrongType.valid, false, 'numeric coding value fails'); + +// GIVEN null input +assert.strictEqual(validateRules(null).valid, false, 'null rules fails'); + +// GIVEN parse error marker +assert.strictEqual(validateRules({ _parseError: true }).valid, false, 'parse error in rules fails'); + +// ---- validateStates ---- + +// GIVEN valid states +const statesOk = validateStates({ states: { 'skill-a': true, 'skill-b': false } }); +assert.strictEqual(statesOk.valid, true, 'valid states passes'); + +// GIVEN states without wrapper key (should accept bare object) +const statesBare = validateStates({ 'skill-a': true }); +assert.strictEqual(statesBare.valid, true, 'bare states object passes'); + +// GIVEN states value that is not boolean +const statesBadType = validateStates({ states: { 'skill-a': 'yes' } }); +assert.strictEqual(statesBadType.valid, false, 'non-boolean state fails'); +assert.ok(statesBadType.error && statesBadType.error.includes('must be boolean'), 'error mentions boolean'); + +// GIVEN states as array +const statesArray = validateStates({ states: ['a', 'b'] }); +assert.strictEqual(statesArray.valid, false, 'array states fails'); + +// GIVEN null input +assert.strictEqual(validateStates(null).valid, false, 'null states fails'); + +// GIVEN parse error marker +assert.strictEqual(validateStates({ _parseError: true }).valid, false, 'parse error in states fails'); + +console.log('validation smoke ok'); diff --git a/scripts/vectorstore-smoke.js b/scripts/vectorstore-smoke.js index 9eee769..dae6731 100644 --- a/scripts/vectorstore-smoke.js +++ b/scripts/vectorstore-smoke.js @@ -8,8 +8,12 @@ const { loadVectorStore, saveVectorStore, upsertVectors, + replaceVectors, searchVectors, cosineSimilarity, + markIndexStale, + clearIndexStale, + getIndexStale, } = require('../server/lib/vectorstore'); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ce-vectorstore-')); @@ -48,7 +52,90 @@ try { assert.strictEqual(reloaded.records.length, 2); assert.strictEqual(results[0]?.skillId, 'skill-a'); assert.strictEqual(cosineSimilarity([1, 0], [1, 0]), 1); + + // ---- replaceVectors ---- + + // GIVEN a store with existing records + // WHEN we replace with a new set + const replaced = replaceVectors( + [ + { + id: 'skill-c:overview:1', + skillId: 'skill-c', + section: 'Overview', + text: 'Brand new skill.', + type: 'knowledge', + sourcePath: 'skill-c/SKILL.md', + vector: [0, 0, 1], + }, + ], + 'fixture-model', + ); + assert.strictEqual(replaced.records.length, 1, 'replaceVectors discards previous records'); + assert.strictEqual(replaced.records[0]?.skillId, 'skill-c', 'replaceVectors keeps new records'); + + // ---- upsertVectors update semantics ---- + + // WHEN we upsert a record with the same ID + const updatedStore = upsertVectors( + store, + [ + { + id: 'skill-a:overview:1', + skillId: 'skill-a', + section: 'Overview', + text: 'Updated text for skill-a.', + type: 'rule', + sourcePath: 'skill-a/SKILL.md', + vector: [1, 0, 0], + }, + ], + 'fixture-model', + ); + assert.strictEqual(updatedStore.records.length, 2, 'upsert keeps same record count'); + const updatedRecord = updatedStore.records.find((r) => r.id === 'skill-a:overview:1'); + assert.strictEqual(updatedRecord?.text, 'Updated text for skill-a.', 'upsert updates existing record'); + + // ---- searchVectors with skillId filter ---- + + const filteredResults = searchVectors(store, [1, 0, 0], { limit: 10, skillId: 'skill-b' }); + assert.ok( + filteredResults.every((r) => r.skillId === 'skill-b'), + 'skillId filter works', + ); + + // ---- searchVectors on empty store ---- + + const emptyResults = searchVectors(loadVectorStore(path.join(tmpDir, 'nonexistent.json')), [1, 0]); + assert.deepStrictEqual(emptyResults, [], 'search on empty store returns empty'); + + // ---- cosineSimilarity edge cases ---- + + assert.strictEqual(cosineSimilarity([0, 0], [1, 0]), 0, 'zero vector returns 0'); + assert.strictEqual(cosineSimilarity([1, 0], [0, 1]), 0, 'orthogonal vectors return 0'); + assert.strictEqual(cosineSimilarity([], []), 0, 'empty vectors return 0'); + assert.strictEqual(cosineSimilarity([1, 0], [-1, 0]), -1, 'opposite vectors return -1'); + assert.ok(Math.abs(cosineSimilarity([1, 1], [1, 1]) - 1) < 0.001, 'parallel vectors return ~1'); + + // ---- loadVectorStore with corrupt file ---- + + const corruptPath = path.join(tmpDir, 'corrupt.json'); + fs.writeFileSync(corruptPath, 'NOT JSON', 'utf8'); + const corruptStore = loadVectorStore(corruptPath); + assert.strictEqual(corruptStore.records.length, 0, 'corrupt file returns empty store'); + + // ---- stale index lifecycle ---- + + markIndexStale('skills changed'); + const staleState = getIndexStale(); + assert.strictEqual(staleState.stale, true, 'after markIndexStale, index is stale'); + assert.strictEqual(staleState.reason, 'skills changed', 'stale reason is preserved'); + + clearIndexStale(); + const clearedState = getIndexStale(); + assert.strictEqual(clearedState.stale, false, 'after clearIndexStale, index is not stale'); + console.log('vectorstore smoke ok'); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); -} \ No newline at end of file +} diff --git a/server/lib/config.js b/server/lib/config.js index 6114dda..f052837 100644 --- a/server/lib/config.js +++ b/server/lib/config.js @@ -26,6 +26,8 @@ const MODES_FILE = path.join(DATA_DIR, 'modes.json'); const KEYS_FILE = path.join(DATA_DIR, '.keys.enc'); const SKILL_CACHE_FILE = path.join(DATA_DIR, 'skill-parse-cache.json'); const DEDUP_FILE = path.join(DATA_DIR, 'dedup.json'); +const PROJECTS_FILE = path.join(DATA_DIR, 'projects.json'); +const PROJECTS_DIR = path.join(DATA_DIR, 'projects'); const MIME = { '.html': 'text/html', @@ -50,5 +52,7 @@ module.exports = { KEYS_FILE, SKILL_CACHE_FILE, DEDUP_FILE, + PROJECTS_FILE, + PROJECTS_DIR, MIME, }; diff --git a/server/lib/handoff-migration.js b/server/lib/handoff-migration.js index 66d2a60..79cbab4 100644 --- a/server/lib/handoff-migration.js +++ b/server/lib/handoff-migration.js @@ -122,7 +122,6 @@ async function migrateLegacyHandoff(input) { const result = createHandoff({ title: entry.title, repo, - thread_tag: entry.slug, body: entry.body, }); if (!result.ok) { diff --git a/server/lib/handoffs.js b/server/lib/handoffs.js index ab81bfc..d4a531c 100644 --- a/server/lib/handoffs.js +++ b/server/lib/handoffs.js @@ -329,9 +329,6 @@ function createHandoff(input) { const repo = input?.repo ? String(input.repo).trim() : ''; const tag = input?.thread_tag ? String(input.thread_tag).trim() : ''; - if (!repo && !tag) { - return { ok: false, error: 'At least one of repo or thread_tag is required' }; - } /** @type {'project' | 'thread' | 'dual'} */ const type = repo && tag ? 'dual' : repo ? 'project' : 'thread'; @@ -350,7 +347,7 @@ function createHandoff(input) { } const taken = new Set([...readBodyDirSlugs(HANDOFFS_DIR), ...readBodyDirSlugs(ARCHIVE_DIR)]); - const slug = uniqueSlug(tag || title, taken); + const slug = uniqueSlug(title, taken); const now = new Date().toISOString(); /** @type {HandoffFrontmatter} */ const fm = { diff --git a/server/lib/modes.js b/server/lib/modes.js index 4c77e5b..b94d045 100644 --- a/server/lib/modes.js +++ b/server/lib/modes.js @@ -61,15 +61,19 @@ function applyMode(modeId) { /** @type {Record} */ const stateMap = { ...(states.states || {}) }; + // Ensure every discovered skill has an entry in the state map. + // New skills default to active so they remain visible and available. Object.keys(SKILL_MAP).forEach((/** @type {string} */ id) => { - stateMap[id] = false; + if (!(id in stateMap)) stateMap[id] = true; }); if (mode.id === 'all') { Object.keys(SKILL_MAP).forEach((/** @type {string} */ id) => { stateMap[id] = true; }); - } else { + } else if (mode.skills.length > 0) { + // Only activate skills the mode explicitly lists; leave all others at + // their current state so skills never disappear when a mode is applied. mode.skills.forEach((/** @type {string} */ id) => { if (SKILL_MAP[id]) stateMap[id] = true; }); diff --git a/server/lib/projects.js b/server/lib/projects.js new file mode 100644 index 0000000..d8393c9 --- /dev/null +++ b/server/lib/projects.js @@ -0,0 +1,121 @@ +// @ts-nocheck — Path-A backlog: file in tsconfig include, opt out until incremental typing is done. See docs/llm-handoff.md. + +// projects.js — Project-scoped context directories + +const fs = require('fs'); +const path = require('path'); +const { DATA_DIR } = require('./config'); + +const PROJECTS_FILE = path.join(DATA_DIR, 'projects.json'); +const PROJECTS_DIR = path.join(DATA_DIR, 'projects'); + +function ensureDirs() { + if (!fs.existsSync(PROJECTS_DIR)) fs.mkdirSync(PROJECTS_DIR, { recursive: true }); +} + +function readRegistry() { + try { + return JSON.parse(fs.readFileSync(PROJECTS_FILE, 'utf8')); + } catch { + return { version: '1.0', projects: [] }; + } +} + +function writeRegistry(data) { + fs.writeFileSync(PROJECTS_FILE, JSON.stringify(data, null, 2), 'utf8'); +} + +function uniqueSlug(seed, taken) { + const base = + String(seed) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) || 'project'; + if (!taken.has(base)) return base; + let n = 2; + while (taken.has(`${base}-${n}`)) n++; + return `${base}-${n}`; +} + +function listProjects() { + const reg = readRegistry(); + return Array.isArray(reg.projects) ? reg.projects : []; +} + +function getProject(slug) { + const projects = listProjects(); + return projects.find((p) => p.slug === slug) || null; +} + +function createProject(input) { + ensureDirs(); + const name = String(input?.name || '').trim(); + const repoPath = input?.path ? String(input.path).trim() : ''; + if (!name) return { ok: false, error: 'name is required' }; + + const reg = readRegistry(); + const taken = new Set((reg.projects || []).map((p) => p.slug)); + const slug = uniqueSlug(name, taken); + const now = new Date().toISOString(); + + const projectDir = path.join(PROJECTS_DIR, slug); + fs.mkdirSync(projectDir, { recursive: true }); + fs.mkdirSync(path.join(projectDir, 'handoffs'), { recursive: true }); + + const defaultMemory = { version: '1.1', entries: [] }; + const defaultRules = { coding: '', general: '', soul: '' }; + fs.writeFileSync(path.join(projectDir, 'memory.json'), JSON.stringify(defaultMemory, null, 2), 'utf8'); + fs.writeFileSync(path.join(projectDir, 'rules.json'), JSON.stringify(defaultRules, null, 2), 'utf8'); + + const project = { + slug, + name, + path: repoPath || undefined, + created: now, + last_touched: now, + }; + if (!Array.isArray(reg.projects)) reg.projects = []; + reg.projects.push(project); + writeRegistry(reg); + return { ok: true, project }; +} + +function deleteProject(slug) { + const reg = readRegistry(); + if (!Array.isArray(reg.projects)) return { ok: false, error: 'No projects' }; + const idx = reg.projects.findIndex((p) => p.slug === slug); + if (idx === -1) return { ok: false, error: 'Project not found' }; + reg.projects.splice(idx, 1); + writeRegistry(reg); + + const projectDir = path.join(PROJECTS_DIR, slug); + try { + fs.rmSync(projectDir, { recursive: true, force: true }); + } catch { + // directory may already be gone + } + return { ok: true }; +} + +function updateProject(slug, patch) { + const reg = readRegistry(); + if (!Array.isArray(reg.projects)) return { ok: false, error: 'No projects' }; + const project = reg.projects.find((p) => p.slug === slug); + if (!project) return { ok: false, error: 'Project not found' }; + if (patch?.name) project.name = String(patch.name).trim(); + if (patch?.path !== undefined) project.path = String(patch.path).trim() || undefined; + project.last_touched = new Date().toISOString(); + writeRegistry(reg); + return { ok: true, project }; +} + +module.exports = { + PROJECTS_DIR, + PROJECTS_FILE, + listProjects, + getProject, + createProject, + deleteProject, + updateProject, +}; diff --git a/server/router.js b/server/router.js index d7b43d1..f3505ce 100644 --- a/server/router.js +++ b/server/router.js @@ -59,6 +59,7 @@ const { } = require('./lib/skill-import'); const { markIndexStale } = require('./lib/vectorstore'); const { handleHandoffRequest, handoffRouteDocs } = require('./lib/handoff-routes'); +const { listProjects, getProject, createProject, deleteProject, updateProject } = require('./lib/projects'); const { apiDocs } = require('./lib/api-docs'); const ALLOWED_INGEST_HOSTS = new Set(['github.com', 'gitlab.com', 'codeberg.org', 'bitbucket.org']); @@ -433,11 +434,31 @@ async function handleRequest(req, res, url) { } // ---- STATES ---- - if (p === '/api/states' && req.method === 'GET') return json(res, readData('skill-states.json')); + if (p === '/api/states' && req.method === 'GET') { + const states = readData('skill-states.json') || {}; + const stateMap = states.states || states; + // Ensure every discovered skill appears in the response so the UI + // never loses sight of a skill just because it's missing from the file. + const SKILL_MAP = scanSkills(); + const merged = typeof stateMap === 'object' && !Array.isArray(stateMap) ? { ...stateMap } : {}; + for (const id of Object.keys(SKILL_MAP)) { + if (!(id in merged)) merged[id] = true; + } + return json(res, { version: states.version || '1.0', last_updated: states.last_updated || '', states: merged }); + } if (p === '/api/states' && req.method === 'POST') { const data = await body(req); const v = validateStates(data); if (!v.valid) return json(res, { ok: false, error: v.error }, 400); + // Merge in any discovered skills missing from the posted states so they + // default to active instead of vanishing from the UI. + const SKILL_MAP = scanSkills(); + const incomingStates = data.states || data; + if (typeof incomingStates === 'object' && !Array.isArray(incomingStates)) { + for (const id of Object.keys(SKILL_MAP)) { + if (!(id in incomingStates)) incomingStates[id] = true; + } + } const backup = readData('skill-states.json'); try { writeData('skill-states.json', data); @@ -714,6 +735,27 @@ async function handleRequest(req, res, url) { return json(res, { ok: true, results, errors, workspaces: data.workspaces }); } + // ---- PROJECTS ---- + if (p === '/api/projects' && req.method === 'GET') { + return json(res, { ok: true, projects: listProjects() }); + } + if (p === '/api/projects' && req.method === 'POST') { + const input = await body(req); + const result = createProject(input); + return json(res, result, result.ok ? 200 : 400); + } + if (p.startsWith('/api/projects/') && req.method === 'PATCH') { + const slug = decodeURIComponent(p.slice('/api/projects/'.length)); + const patch = await body(req); + const result = updateProject(slug, patch); + return json(res, result, result.ok ? 200 : 404); + } + if (p.startsWith('/api/projects/') && req.method === 'DELETE') { + const slug = decodeURIComponent(p.slice('/api/projects/'.length)); + const result = deleteProject(slug); + return json(res, result, result.ok ? 200 : 404); + } + return null; // Not an API route } diff --git a/ui/app.js b/ui/app.js index d58701c..5576a33 100644 --- a/ui/app.js +++ b/ui/app.js @@ -102,6 +102,7 @@ async function boot() { SkillsTab.init(); MemoryTab.init(); if (typeof HandoffsTab !== 'undefined') await HandoffsTab.init(); + if (typeof ProjectsTab !== 'undefined') await ProjectsTab.init(); ConfigTab.init(); await ModesTab.init(); if (typeof CompileTab !== 'undefined') await CompileTab.init(); diff --git a/ui/handoffs.js b/ui/handoffs.js index 8f1d9b8..2178794 100644 --- a/ui/handoffs.js +++ b/ui/handoffs.js @@ -364,6 +364,8 @@ const HandoffsTab = (() => { const el = document.getElementById(id); if (el) /** @type {HTMLInputElement|HTMLTextAreaElement} */ (el).value = ''; }); + const browseBtn = overlay.querySelector('.local-browse-btn'); + if (browseBtn) browseBtn.hidden = !window.contextEngineDesktop?.selectFolder; overlay.classList.add('open'); setTimeout(() => document.getElementById('handoff-modal-title')?.focus(), 0); } @@ -373,13 +375,27 @@ const HandoffsTab = (() => { document.getElementById('handoff-modal-overlay')?.classList.remove('open'); } + async function browseRepoPath() { + const picker = window.contextEngineDesktop?.selectFolder; + if (!picker) return Toast.error('Folder picker not available in this environment'); + try { + const picked = await picker({ title: 'Select repository folder' }); + if (picked) { + const el = /** @type {HTMLInputElement|null} */ (document.getElementById('handoff-modal-repo')); + if (el) el.value = picked; + } + } catch (err) { + console.error('handoffs: folder picker failed', err); + Toast.error('Could not open folder picker'); + } + } + async function createFromModal() { const title = /** @type {HTMLInputElement|null} */ (document.getElementById('handoff-modal-title'))?.value.trim(); const thread_tag = /** @type {HTMLInputElement|null} */ (document.getElementById('handoff-modal-thread'))?.value.trim(); const repo = /** @type {HTMLInputElement|null} */ (document.getElementById('handoff-modal-repo'))?.value.trim(); const body = /** @type {HTMLTextAreaElement|null} */ (document.getElementById('handoff-modal-body'))?.value || ''; if (!title) return Toast.error('Title is required'); - if (!thread_tag && !repo) return Toast.error('Add a thread tag or repo path'); const result = await apiFetch('/handoffs', 'POST', { title, thread_tag, repo, body }); if (!result?.ok) return; closeAddModal(); @@ -489,5 +505,6 @@ const HandoffsTab = (() => { openAddModal, closeAddModal, createFromModal, + browseRepoPath, }; })(); diff --git a/ui/index.html b/ui/index.html index 213e6a3..de16b03 100644 --- a/ui/index.html +++ b/ui/index.html @@ -87,6 +87,20 @@ Context + + + +
+ + +