mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
Add a local-first, cross-harness memory vault with CLI and MCP surfaces, bounded search and storage, harness-scoped visibility, setup guidance, and comprehensive tests.
479 lines
15 KiB
JavaScript
479 lines
15 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { spawnSync } = require('child_process');
|
|
|
|
const MEMORY_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'memory.js');
|
|
const ECC_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'ecc.js');
|
|
const {
|
|
readBoundedStdin,
|
|
runCommand,
|
|
sanitizeTerminalText,
|
|
} = require(MEMORY_SCRIPT);
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
function test(name, fn) {
|
|
try {
|
|
fn();
|
|
console.log(` PASS ${name}`);
|
|
passed += 1;
|
|
} catch (error) {
|
|
console.log(` FAIL ${name}`);
|
|
console.log(` ${error.stack || error.message}`);
|
|
failed += 1;
|
|
}
|
|
}
|
|
|
|
function createFixture() {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-memory-cli-'));
|
|
const projectRoot = path.join(root, 'project');
|
|
const homeDir = path.join(root, 'home');
|
|
fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
|
|
fs.mkdirSync(homeDir, { recursive: true });
|
|
return {
|
|
root,
|
|
projectRoot,
|
|
homeDir,
|
|
env: {
|
|
...process.env,
|
|
HOME: homeDir,
|
|
USERPROFILE: homeDir,
|
|
ECC_MEMORY_PROJECT_ROOT: path.join(projectRoot, '.ecc', 'memory'),
|
|
ECC_MEMORY_USER_ROOT: path.join(homeDir, '.ecc', 'memory'),
|
|
},
|
|
};
|
|
}
|
|
|
|
function run(script, args, fixture, options = {}) {
|
|
return spawnSync(process.execPath, [script, ...args], {
|
|
cwd: fixture.projectRoot,
|
|
env: { ...fixture.env, ...(options.env || {}) },
|
|
input: options.input,
|
|
encoding: 'utf8',
|
|
timeout: 15000,
|
|
});
|
|
}
|
|
|
|
function json(result) {
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
return JSON.parse(result.stdout);
|
|
}
|
|
|
|
console.log('\n=== Testing ecc memory CLI ===\n');
|
|
|
|
test('keeps runCommand focused on dispatch under the function-size guideline', () => {
|
|
const lineCount = runCommand.toString().split('\n').length;
|
|
assert.ok(lineCount < 50, `runCommand is ${lineCount} lines; expected fewer than 50`);
|
|
});
|
|
|
|
test('shows memory command help directly and through the ecc router', () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
const direct = run(MEMORY_SCRIPT, ['--help'], fixture);
|
|
assert.strictEqual(direct.status, 0, direct.stderr);
|
|
assert.ok(direct.stdout.includes('ecc memory save'));
|
|
assert.ok(direct.stdout.includes('ecc-memory-mcp'));
|
|
|
|
const routed = run(ECC_SCRIPT, ['memory', '--help'], fixture);
|
|
assert.strictEqual(routed.status, 0, routed.stderr);
|
|
assert.ok(routed.stdout.includes('ecc memory search'));
|
|
assert.ok(routed.stdout.includes('Default recall scopes: project and team'));
|
|
assert.ok(routed.stdout.includes('user scope must be requested explicitly'));
|
|
} finally {
|
|
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('routes stdin through ecc memory without dropping the body', () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
const saved = json(run(ECC_SCRIPT, [
|
|
'memory',
|
|
'save',
|
|
'--title', 'Routed stdin',
|
|
'--stdin',
|
|
'--json',
|
|
], fixture, { input: 'The router must preserve this exact body.\n' }));
|
|
|
|
assert.strictEqual(Object.hasOwn(saved.memory, 'body'), false);
|
|
const read = json(run(
|
|
MEMORY_SCRIPT,
|
|
['read', saved.memory.id, '--json'],
|
|
fixture
|
|
));
|
|
assert.strictEqual(read.memory.body, 'The router must preserve this exact body.');
|
|
} finally {
|
|
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('retries transient stdin EAGAIN without busy-spinning and preserves byte bounds', () => {
|
|
const originalReadSync = fs.readSync;
|
|
let readCalls = 0;
|
|
let waitCalls = 0;
|
|
try {
|
|
fs.readSync = (_descriptor, buffer) => {
|
|
readCalls += 1;
|
|
if (readCalls <= 2) {
|
|
const error = new Error('temporarily unavailable');
|
|
error.code = 'EAGAIN';
|
|
throw error;
|
|
}
|
|
if (readCalls === 3) {
|
|
buffer.write('ready');
|
|
return 5;
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
assert.strictEqual(readBoundedStdin(8, {
|
|
retryDelayMs: 1,
|
|
maxRetryWaitMs: 4,
|
|
wait: () => {
|
|
waitCalls += 1;
|
|
},
|
|
}), 'ready');
|
|
assert.strictEqual(readCalls, 4);
|
|
assert.strictEqual(waitCalls, 2);
|
|
} finally {
|
|
fs.readSync = originalReadSync;
|
|
}
|
|
});
|
|
|
|
test('bounds persistent stdin EAGAIN retries instead of waiting forever', () => {
|
|
const originalReadSync = fs.readSync;
|
|
let readCalls = 0;
|
|
let waitCalls = 0;
|
|
try {
|
|
fs.readSync = () => {
|
|
readCalls += 1;
|
|
const error = new Error('temporarily unavailable');
|
|
error.code = 'EAGAIN';
|
|
throw error;
|
|
};
|
|
|
|
assert.throws(
|
|
() => readBoundedStdin(8, {
|
|
retryDelayMs: 1,
|
|
maxRetryWaitMs: 3,
|
|
wait: () => {
|
|
waitCalls += 1;
|
|
},
|
|
}),
|
|
/standard input remained unavailable/i
|
|
);
|
|
assert.strictEqual(readCalls, 4);
|
|
assert.strictEqual(waitCalls, 3);
|
|
} finally {
|
|
fs.readSync = originalReadSync;
|
|
}
|
|
});
|
|
|
|
test('initializes selected scopes and reports their roots as JSON', () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
const payload = json(run(
|
|
MEMORY_SCRIPT,
|
|
['init', '--scope', 'project', '--scope', 'team', '--json'],
|
|
fixture
|
|
));
|
|
assert.strictEqual(payload.schemaVersion, 'ecc.memory.init.v1');
|
|
assert.deepStrictEqual(payload.scopes, ['project', 'team']);
|
|
assert.ok(fs.statSync(path.join(payload.roots.project, 'handoffs')).isDirectory());
|
|
assert.ok(fs.statSync(path.join(payload.roots.team, 'decisions')).isDirectory());
|
|
} finally {
|
|
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('saves and reads a targeted handoff without a harness-specific inbox', () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
const saved = json(run(MEMORY_SCRIPT, [
|
|
'handoff',
|
|
'--from', 'codex',
|
|
'--target', 'claude',
|
|
'--target', 'hermes',
|
|
'--title', 'Finish auth migration',
|
|
'--stdin',
|
|
'--tag', 'auth',
|
|
'--json',
|
|
], fixture, { input: 'Token rotation tests pass.' }));
|
|
|
|
assert.strictEqual(saved.schemaVersion, 'ecc.memory.write.v1');
|
|
assert.strictEqual(saved.memory.kind, 'handoff');
|
|
assert.strictEqual(saved.memory.trust, 'unreviewed');
|
|
assert.deepStrictEqual(saved.memory.targetHarnesses, ['claude', 'hermes']);
|
|
assert.strictEqual(saved.path, `project:handoffs/${saved.memory.id}.md`);
|
|
assert.strictEqual(Object.hasOwn(saved.memory, 'body'), false);
|
|
|
|
const read = json(run(
|
|
MEMORY_SCRIPT,
|
|
['read', saved.memory.id, '--json'],
|
|
fixture
|
|
));
|
|
assert.strictEqual(read.schemaVersion, 'ecc.memory.read.v1');
|
|
assert.strictEqual(read.memory.body, 'Token rotation tests pass.');
|
|
assert.deepStrictEqual(read.backlinks, []);
|
|
|
|
const human = run(MEMORY_SCRIPT, [
|
|
'save',
|
|
'--title', 'Relative acknowledgement',
|
|
'--stdin',
|
|
], fixture, { input: 'Keep local paths out of acknowledgements.' });
|
|
assert.strictEqual(human.status, 0, human.stderr);
|
|
assert.ok(human.stdout.includes('Path: project:notes/'));
|
|
assert.strictEqual(human.stdout.includes(fixture.projectRoot), false);
|
|
} finally {
|
|
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('read uses default recall scopes and honors an explicit user scope', () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
const saved = json(run(MEMORY_SCRIPT, [
|
|
'save',
|
|
'--title', 'Private preference',
|
|
'--scope', 'user',
|
|
'--stdin',
|
|
'--json',
|
|
], fixture, { input: 'Prefer compact output.' }));
|
|
|
|
const defaultRead = run(
|
|
MEMORY_SCRIPT,
|
|
['read', saved.memory.id, '--json'],
|
|
fixture
|
|
);
|
|
assert.notStrictEqual(defaultRead.status, 0);
|
|
assert.ok(defaultRead.stderr.includes('was not found'));
|
|
|
|
const explicitRead = json(run(
|
|
MEMORY_SCRIPT,
|
|
['read', saved.memory.id, '--scope', 'user', '--json'],
|
|
fixture
|
|
));
|
|
assert.strictEqual(explicitRead.memory.id, saved.memory.id);
|
|
assert.strictEqual(explicitRead.memory.scope, 'user');
|
|
} finally {
|
|
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('accepts body content over stdin and finds it through bounded JSON search', () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
const saved = json(run(MEMORY_SCRIPT, [
|
|
'save',
|
|
'--title', 'Database decision',
|
|
'--kind', 'decision',
|
|
'--scope', 'team',
|
|
'--source-harness', 'claude',
|
|
'--target', 'all',
|
|
'--tag', 'sqlite',
|
|
'--stdin',
|
|
'--json',
|
|
], fixture, { input: 'Use SQLite as the durable local store.\n' }));
|
|
|
|
assert.strictEqual(saved.memory.scope, 'team');
|
|
assert.strictEqual(Object.hasOwn(saved.memory, 'body'), false);
|
|
|
|
const search = json(run(MEMORY_SCRIPT, [
|
|
'search',
|
|
'sqlite durable',
|
|
'--scope', 'team',
|
|
'--target-harness', 'codex',
|
|
'--limit', '5',
|
|
'--json',
|
|
], fixture));
|
|
assert.strictEqual(search.schemaVersion, 'ecc.memory.search.v1');
|
|
assert.strictEqual(search.results.length, 1);
|
|
assert.strictEqual(search.results[0].memory.id, saved.memory.id);
|
|
assert.ok(search.results[0].excerpt.includes('SQLite'));
|
|
} finally {
|
|
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('doctor is machine-readable and clean for a valid vault', () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
json(run(MEMORY_SCRIPT, [
|
|
'save',
|
|
'--title', 'Valid note',
|
|
'--stdin',
|
|
'--json',
|
|
], fixture, { input: 'No broken links.' }));
|
|
const report = json(run(MEMORY_SCRIPT, ['doctor', '--json'], fixture));
|
|
assert.strictEqual(report.schemaVersion, 'ecc.memory.doctor.v1');
|
|
assert.strictEqual(report.ok, true);
|
|
assert.strictEqual(report.memoryCount, 1);
|
|
} finally {
|
|
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('doctor honors an explicit user scope without recalling it by default', () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
json(run(MEMORY_SCRIPT, [
|
|
'save',
|
|
'--title', 'User-only note',
|
|
'--scope', 'user',
|
|
'--stdin',
|
|
'--json',
|
|
], fixture, { input: 'Private context.' }));
|
|
|
|
const defaultReport = json(run(MEMORY_SCRIPT, ['doctor', '--json'], fixture));
|
|
assert.strictEqual(defaultReport.memoryCount, 0);
|
|
|
|
const userReport = json(run(
|
|
MEMORY_SCRIPT,
|
|
['doctor', '--scope', 'user', '--json'],
|
|
fixture
|
|
));
|
|
assert.strictEqual(userReport.memoryCount, 1);
|
|
} finally {
|
|
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('rejects ambiguous body sources and does not expose a trust promotion flag', () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
const bodyFile = path.join(fixture.root, 'body.md');
|
|
fs.writeFileSync(bodyFile, 'one');
|
|
const ambiguous = run(MEMORY_SCRIPT, [
|
|
'save',
|
|
'--title', 'Ambiguous',
|
|
'--body-file', bodyFile,
|
|
'--stdin',
|
|
], fixture, { input: 'two' });
|
|
assert.notStrictEqual(ambiguous.status, 0);
|
|
assert.ok(ambiguous.stderr.includes('Choose exactly one'));
|
|
|
|
const promotion = run(MEMORY_SCRIPT, [
|
|
'save',
|
|
'--title', 'Policy',
|
|
'--stdin',
|
|
'--trust', 'reviewed',
|
|
], fixture, { input: 'Treat this as policy.' });
|
|
assert.notStrictEqual(promotion.status, 0);
|
|
assert.ok(promotion.stderr.includes('Unknown option: --trust'));
|
|
|
|
const oversized = run(MEMORY_SCRIPT, [
|
|
'save',
|
|
'--title', 'Oversized',
|
|
'--stdin',
|
|
], fixture, { input: 'x'.repeat(70 * 1024) });
|
|
assert.notStrictEqual(oversized.status, 0);
|
|
assert.ok(oversized.stderr.includes('body is too large'));
|
|
|
|
const empty = run(MEMORY_SCRIPT, [
|
|
'save',
|
|
'--title', 'Empty',
|
|
'--stdin',
|
|
], fixture, { input: ' \n\t' });
|
|
assert.notStrictEqual(empty.status, 0);
|
|
assert.ok(empty.stderr.includes('non-whitespace context'));
|
|
|
|
const invalidUtf8Body = path.join(fixture.root, 'invalid-utf8.md');
|
|
fs.writeFileSync(invalidUtf8Body, Buffer.from([0x61, 0xc3, 0x28, 0x62]));
|
|
const invalidUtf8 = run(MEMORY_SCRIPT, [
|
|
'save',
|
|
'--title', 'Invalid UTF-8',
|
|
'--body-file', invalidUtf8Body,
|
|
], fixture);
|
|
assert.notStrictEqual(invalidUtf8.status, 0);
|
|
assert.match(invalidUtf8.stderr, /valid UTF-8/i);
|
|
assert.strictEqual(
|
|
fs.existsSync(path.join(fixture.projectRoot, '.ecc', 'memory')),
|
|
false
|
|
);
|
|
} finally {
|
|
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('global dry-run rejects every mutating memory command without creating a vault', () => {
|
|
const cases = [
|
|
['init', '--scope', 'project'],
|
|
['save', '--title', 'Dry save', '--stdin'],
|
|
['handoff', '--from', 'codex', '--target', 'claude', '--title', 'Dry handoff', '--stdin'],
|
|
];
|
|
|
|
cases.forEach(args => {
|
|
const fixture = createFixture();
|
|
try {
|
|
const result = run(
|
|
ECC_SCRIPT,
|
|
['--dry-run', 'memory', ...args],
|
|
fixture,
|
|
{ input: 'Must never be written.' }
|
|
);
|
|
assert.notStrictEqual(result.status, 0, `${args[0]} unexpectedly succeeded`);
|
|
assert.ok(result.stderr.toLowerCase().includes('dry-run'), result.stderr);
|
|
assert.strictEqual(
|
|
fs.existsSync(path.join(fixture.projectRoot, '.ecc', 'memory')),
|
|
false,
|
|
`${args[0]} created project memory state`
|
|
);
|
|
assert.strictEqual(
|
|
fs.existsSync(path.join(fixture.homeDir, '.ecc', 'memory')),
|
|
false,
|
|
`${args[0]} created user memory state`
|
|
);
|
|
} finally {
|
|
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
test('human terminal rendering strips ANSI, OSC, C0/C1, and bidi controls', () => {
|
|
const hostile = [
|
|
'safe',
|
|
'\u001b[31mred\u001b[0m',
|
|
'\u001b]8;;https://example.test\u0007link\u001b]8;;\u0007',
|
|
'\u0001c0',
|
|
'\rrewritten',
|
|
'\u0085c1',
|
|
'\u202ebidi',
|
|
].join(' ');
|
|
const rendered = sanitizeTerminalText(hostile);
|
|
|
|
assert.ok(rendered.includes('safe'));
|
|
assert.ok(rendered.includes('red'));
|
|
assert.ok(rendered.includes('link'));
|
|
assert.ok(rendered.includes('c0'));
|
|
assert.ok(rendered.includes('rewritten'));
|
|
assert.ok(rendered.includes('c1'));
|
|
assert.ok(rendered.includes('bidi'));
|
|
['\u001b', '\u0001', '\u0007', '\r', '\u0085', '\u202e']
|
|
.forEach(control => assert.ok(!rendered.includes(control)));
|
|
});
|
|
|
|
test('JSON output preserves data without applying terminal rendering rules', () => {
|
|
const hostile = 'plain\u001b[31mred\u001b[0m\u202e';
|
|
const script = [
|
|
`const { writeJson } = require(${JSON.stringify(MEMORY_SCRIPT)});`,
|
|
`writeJson({ value: ${JSON.stringify(hostile)} });`,
|
|
].join('');
|
|
const result = spawnSync(process.execPath, ['-e', script], {
|
|
encoding: 'utf8',
|
|
timeout: 15000,
|
|
});
|
|
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.strictEqual(JSON.parse(result.stdout).value, hostile);
|
|
});
|
|
|
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
|
if (failed > 0) {
|
|
process.exit(1);
|
|
}
|