mirror of
https://github.com/EvoMap/evolver.git
synced 2026-09-18 21:47:53 +08:00
489 lines
19 KiB
JavaScript
489 lines
19 KiB
JavaScript
const { describe, it, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const crypto = require('crypto');
|
|
|
|
const {
|
|
isPublicNonObfuscated,
|
|
isInCooldown,
|
|
isDuplicateDiff,
|
|
computeDiffHash,
|
|
buildPRTitle,
|
|
buildPRBody,
|
|
readState,
|
|
writeState,
|
|
recordPR,
|
|
_loadObfuscatedFromManifest,
|
|
_resetObfuscatedCache,
|
|
_setManifestRetryTtlForTests,
|
|
} = require('../src/gep/selfPR');
|
|
|
|
const _OBFUSCATED_FILES = _loadObfuscatedFromManifest();
|
|
assert.ok(_OBFUSCATED_FILES, 'public.manifest.json must load in test env');
|
|
|
|
// --- isPublicNonObfuscated ---
|
|
|
|
describe('isPublicNonObfuscated', () => {
|
|
it('accepts non-obfuscated src files', () => {
|
|
assert.equal(isPublicNonObfuscated('src/gep/signals.js'), true);
|
|
assert.equal(isPublicNonObfuscated('src/gep/assetStore.js'), true);
|
|
assert.equal(isPublicNonObfuscated('src/gep/bridge.js'), true);
|
|
assert.equal(isPublicNonObfuscated('src/gep/selfPR.js'), true);
|
|
assert.equal(isPublicNonObfuscated('src/gep/issueReporter.js'), true);
|
|
assert.equal(isPublicNonObfuscated('src/gep/a2a.js'), true);
|
|
});
|
|
|
|
it('rejects obfuscated files', () => {
|
|
assert.equal(isPublicNonObfuscated('src/evolve.js'), false);
|
|
assert.equal(isPublicNonObfuscated('src/gep/solidify.js'), false);
|
|
assert.equal(isPublicNonObfuscated('src/gep/selector.js'), false);
|
|
assert.equal(isPublicNonObfuscated('src/gep/mutation.js'), false);
|
|
assert.equal(isPublicNonObfuscated('src/gep/prompt.js'), false);
|
|
});
|
|
|
|
it('accepts index.js and scripts', () => {
|
|
assert.equal(isPublicNonObfuscated('index.js'), true);
|
|
assert.equal(isPublicNonObfuscated('scripts/build_public.js'), true);
|
|
});
|
|
|
|
it('rejects excluded paths', () => {
|
|
assert.equal(isPublicNonObfuscated('docs/README.md'), false);
|
|
assert.equal(isPublicNonObfuscated('memory/evolution/state.json'), false);
|
|
assert.equal(isPublicNonObfuscated('dist-public/index.js'), false);
|
|
});
|
|
|
|
it('rejects empty or invalid paths', () => {
|
|
assert.equal(isPublicNonObfuscated(''), false);
|
|
assert.equal(isPublicNonObfuscated(null), false);
|
|
assert.equal(isPublicNonObfuscated(undefined), false);
|
|
});
|
|
|
|
it('normalizes Windows-style paths', () => {
|
|
assert.equal(isPublicNonObfuscated('.\\src\\gep\\signals.js'), true);
|
|
assert.equal(isPublicNonObfuscated('.\\src\\evolve.js'), false);
|
|
});
|
|
|
|
it('covers all obfuscated files from manifest', () => {
|
|
assert.ok(_OBFUSCATED_FILES.size >= 26, 'at least 26 obfuscated files expected');
|
|
for (const f of _OBFUSCATED_FILES) {
|
|
assert.equal(isPublicNonObfuscated(f), false, f + ' should be rejected');
|
|
}
|
|
});
|
|
|
|
it('rejects glob patterns in manifest obfuscate list (fail-safe)', () => {
|
|
const manifestPath = path.join(__dirname, '..', 'public.manifest.json');
|
|
const original = fs.readFileSync(manifestPath, 'utf8');
|
|
const tampered = JSON.parse(original);
|
|
tampered.obfuscate.push('src/gep/*.js');
|
|
fs.writeFileSync(manifestPath, JSON.stringify(tampered));
|
|
try {
|
|
_resetObfuscatedCache();
|
|
const result = _loadObfuscatedFromManifest();
|
|
assert.equal(result, null, 'loader must fail-safe on glob patterns');
|
|
} finally {
|
|
fs.writeFileSync(manifestPath, original);
|
|
_resetObfuscatedCache();
|
|
}
|
|
});
|
|
|
|
it('OBFUSCATED_FILES stays in sync with public.manifest.json (no drift)', () => {
|
|
const manifestPath = path.join(__dirname, '..', 'public.manifest.json');
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
// Apply the same normalization as loadObfuscatedFromManifest in selfPR.js so a
|
|
// future manifest entry like `./src/foo.js` does not produce a false-fail drift.
|
|
const normalize = (f) => String(f || '').replace(/\\/g, '/').replace(/^\.\/+/, '');
|
|
const manifestList = new Set((manifest.obfuscate || []).map(normalize));
|
|
// Every file the build pipeline obfuscates must be rejected by self-PR.
|
|
for (const f of manifestList) {
|
|
assert.ok(_OBFUSCATED_FILES.has(f), f + ' is in public.manifest.json but missing from selfPR OBFUSCATED_FILES');
|
|
assert.equal(isPublicNonObfuscated(f), false, f + ' should be rejected by isPublicNonObfuscated');
|
|
}
|
|
// And the set should not have stale entries that the manifest no longer obfuscates.
|
|
for (const f of _OBFUSCATED_FILES) {
|
|
assert.ok(manifestList.has(f), f + ' is in selfPR OBFUSCATED_FILES but not in public.manifest.json');
|
|
}
|
|
assert.equal(_OBFUSCATED_FILES.size, manifestList.size, 'set sizes must match');
|
|
});
|
|
});
|
|
|
|
// --- loadObfuscatedFromManifest: retry after transient failure ---
|
|
|
|
describe('loadObfuscatedFromManifest retry after transient failure', () => {
|
|
// Pre-fix: a single failed read of public.manifest.json (transient FS
|
|
// error, NFS hiccup, build script mid-write, permission flap) cached
|
|
// null forever. isPublicNonObfuscated would then reject every file via
|
|
// the fail-safe branch, silently disabling self-PR for the entire
|
|
// daemon lifetime with no recovery short of a process restart. With
|
|
// the retry-after-TTL fix, the next call after MANIFEST_RETRY_TTL_MS
|
|
// re-reads the manifest and recovers.
|
|
|
|
const origReadFileSync = fs.readFileSync;
|
|
let manifestFailCount = 0;
|
|
|
|
beforeEach(() => {
|
|
_resetObfuscatedCache();
|
|
manifestFailCount = 0;
|
|
// Set TTL to 0 so any failed-load timestamp is immediately expired;
|
|
// tests don't have to sleep to exercise the retry path.
|
|
_setManifestRetryTtlForTests(0);
|
|
fs.readFileSync = function (p, ...rest) {
|
|
if (manifestFailCount > 0 && String(p).endsWith('public.manifest.json')) {
|
|
manifestFailCount -= 1;
|
|
const err = new Error('simulated transient FS error');
|
|
err.code = 'EIO';
|
|
throw err;
|
|
}
|
|
return origReadFileSync.call(fs, p, ...rest);
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.readFileSync = origReadFileSync;
|
|
_resetObfuscatedCache();
|
|
});
|
|
|
|
it('retries after a transient failure and recovers on the next call', () => {
|
|
manifestFailCount = 1;
|
|
const first = _loadObfuscatedFromManifest();
|
|
assert.equal(first, null, 'first call must surface the failure as cached null');
|
|
// TTL=0 from beforeEach means the very next call is allowed to retry.
|
|
const second = _loadObfuscatedFromManifest();
|
|
assert.ok(second instanceof Set, 'second call must retry and load the real manifest');
|
|
assert.ok(second.size > 0, 'real manifest must have at least one obfuscated entry');
|
|
});
|
|
|
|
it('does NOT retry while inside the TTL window', () => {
|
|
// Restore a non-zero TTL so the retry guard actually engages.
|
|
_setManifestRetryTtlForTests(5 * 60 * 1000);
|
|
manifestFailCount = 1;
|
|
const first = _loadObfuscatedFromManifest();
|
|
assert.equal(first, null);
|
|
// Inside the TTL window: even with the FS now working, the cached null
|
|
// is returned without re-reading from disk. (manifestFailCount stays at
|
|
// 0 the whole time, proving readFileSync was not invoked on the manifest
|
|
// path for the second call.)
|
|
let readsDuringRetry = 0;
|
|
const prev = fs.readFileSync;
|
|
fs.readFileSync = function (p, ...rest) {
|
|
if (String(p).endsWith('public.manifest.json')) readsDuringRetry += 1;
|
|
return prev.call(fs, p, ...rest);
|
|
};
|
|
try {
|
|
const second = _loadObfuscatedFromManifest();
|
|
assert.equal(second, null, 'cached null must persist within the TTL window');
|
|
assert.equal(readsDuringRetry, 0, 'no FS read should happen while inside TTL');
|
|
} finally {
|
|
fs.readFileSync = prev;
|
|
}
|
|
});
|
|
|
|
it('keeps a successful load sticky (no re-read once loaded)', () => {
|
|
// Healthy load on the first call.
|
|
const first = _loadObfuscatedFromManifest();
|
|
assert.ok(first instanceof Set);
|
|
// Now force the FS to start failing — the cached Set must still be
|
|
// returned without re-reading, because successful loads are sticky
|
|
// (manifest is read-only at runtime).
|
|
manifestFailCount = 99;
|
|
const second = _loadObfuscatedFromManifest();
|
|
assert.equal(second, first, 'successful cache must be returned by identity');
|
|
assert.equal(manifestFailCount, 99, 'no FS read should be triggered after success');
|
|
});
|
|
});
|
|
|
|
// --- buildPRTitle ---
|
|
|
|
describe('buildPRTitle', () => {
|
|
it('includes mutation rationale', () => {
|
|
const title = buildPRTitle({ category: 'optimize', risk: 'low', rationale: 'Improve signal extraction performance' });
|
|
assert.ok(title.startsWith('[Auto-Mutation]'));
|
|
assert.ok(title.includes('Improve signal extraction performance'));
|
|
});
|
|
|
|
it('truncates long rationale to 80 chars', () => {
|
|
const longRationale = 'A'.repeat(200);
|
|
const title = buildPRTitle({ rationale: longRationale });
|
|
assert.ok(title.length <= '[Auto-Mutation] '.length + 80);
|
|
});
|
|
|
|
it('handles missing mutation', () => {
|
|
const title = buildPRTitle(null);
|
|
assert.ok(title.includes('[Auto-Mutation]'));
|
|
assert.ok(title.includes('self-optimization'));
|
|
});
|
|
|
|
it('strips newlines from rationale', () => {
|
|
const title = buildPRTitle({ rationale: 'Line one\nLine two\r\nLine three' });
|
|
assert.ok(!title.includes('\n'));
|
|
assert.ok(!title.includes('\r'));
|
|
});
|
|
});
|
|
|
|
// --- buildPRBody ---
|
|
|
|
describe('buildPRBody', () => {
|
|
it('includes all required sections', () => {
|
|
const body = buildPRBody({
|
|
capsule: {
|
|
id: 'cap_test_12345678',
|
|
outcome: { score: 0.88 },
|
|
success_streak: 3,
|
|
trigger: ['perf_bottleneck', 'log_error'],
|
|
},
|
|
mutation: { category: 'optimize', risk: 'low', rationale: 'Speed up signal extraction' },
|
|
gene: { id: 'gene_perf_opt' },
|
|
blastRadius: { files: 2, lines: 45, all_changed_files: ['src/gep/signals.js', 'src/gep/a2a.js'] },
|
|
});
|
|
|
|
assert.ok(body.includes('## Mutation Summary'));
|
|
assert.ok(body.includes('## Rationale'));
|
|
assert.ok(body.includes('## Changed Files'));
|
|
assert.ok(body.includes('## Blast Radius'));
|
|
assert.ok(body.includes('optimize'));
|
|
assert.ok(body.includes('low'));
|
|
assert.ok(body.includes('0.880'));
|
|
assert.ok(body.includes('gene_perf_opt'));
|
|
assert.ok(body.includes('cap_test_12345678'));
|
|
assert.ok(body.includes('src/gep/signals.js'));
|
|
assert.ok(body.includes('auto-generated by evolver self-evolution'));
|
|
});
|
|
|
|
it('handles missing optional fields', () => {
|
|
const body = buildPRBody({
|
|
capsule: { id: 'c1', outcome: { score: 0.9 }, success_streak: 4 },
|
|
mutation: null,
|
|
gene: null,
|
|
blastRadius: null,
|
|
});
|
|
assert.ok(body.includes('unknown'));
|
|
assert.ok(body.includes('auto-generated'));
|
|
});
|
|
});
|
|
|
|
// --- State management (cooldown, dedup) ---
|
|
|
|
describe('state management', () => {
|
|
let tmpDir;
|
|
let origEvolutionDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selfpr-test-'));
|
|
origEvolutionDir = process.env.EVOLUTION_DIR;
|
|
process.env.EVOLUTION_DIR = tmpDir;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (origEvolutionDir !== undefined) {
|
|
process.env.EVOLUTION_DIR = origEvolutionDir;
|
|
} else {
|
|
delete process.env.EVOLUTION_DIR;
|
|
}
|
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
|
});
|
|
|
|
it('readState returns defaults when no state file', () => {
|
|
const state = readState();
|
|
assert.equal(state.lastPRAt, null);
|
|
assert.ok(Array.isArray(state.recentDiffHashes));
|
|
assert.equal(state.recentDiffHashes.length, 0);
|
|
});
|
|
|
|
it('writeState and readState round-trip', () => {
|
|
const testState = { lastPRAt: '2026-01-01T00:00:00.000Z', recentDiffHashes: ['abc123'] };
|
|
writeState(testState);
|
|
const loaded = readState();
|
|
assert.equal(loaded.lastPRAt, testState.lastPRAt);
|
|
assert.deepEqual(loaded.recentDiffHashes, testState.recentDiffHashes);
|
|
});
|
|
|
|
it('isInCooldown returns false when no prior PR', () => {
|
|
assert.equal(isInCooldown(), false);
|
|
});
|
|
|
|
it('isInCooldown returns true within cooldown window', () => {
|
|
writeState({ lastPRAt: new Date().toISOString(), recentDiffHashes: [] });
|
|
assert.equal(isInCooldown(), true);
|
|
});
|
|
|
|
it('isInCooldown returns false when cooldown expired', () => {
|
|
const old = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
|
|
writeState({ lastPRAt: old, recentDiffHashes: [] });
|
|
assert.equal(isInCooldown(), false);
|
|
});
|
|
|
|
it('isDuplicateDiff returns false when no history', () => {
|
|
assert.equal(isDuplicateDiff('abc123'), false);
|
|
});
|
|
|
|
it('isDuplicateDiff returns true for known hash', () => {
|
|
writeState({ lastPRAt: null, recentDiffHashes: ['abc123', 'def456'] });
|
|
assert.equal(isDuplicateDiff('abc123'), true);
|
|
assert.equal(isDuplicateDiff('xyz999'), false);
|
|
});
|
|
|
|
it('recordPR updates state correctly', () => {
|
|
recordPR('hash_one');
|
|
const s1 = readState();
|
|
assert.ok(s1.lastPRAt);
|
|
assert.ok(s1.recentDiffHashes.includes('hash_one'));
|
|
|
|
recordPR('hash_two');
|
|
const s2 = readState();
|
|
assert.ok(s2.recentDiffHashes.includes('hash_one'));
|
|
assert.ok(s2.recentDiffHashes.includes('hash_two'));
|
|
});
|
|
|
|
it('recordPR caps at 20 entries', () => {
|
|
for (let i = 0; i < 25; i++) {
|
|
recordPR('hash_' + i);
|
|
}
|
|
const s = readState();
|
|
assert.equal(s.recentDiffHashes.length, 20);
|
|
assert.ok(!s.recentDiffHashes.includes('hash_0'));
|
|
assert.ok(s.recentDiffHashes.includes('hash_24'));
|
|
});
|
|
|
|
it('writeState swallows write errors silently by default', () => {
|
|
const origDebug = process.env.DEBUG;
|
|
const origEvolverDebug = process.env.EVOLVER_DEBUG;
|
|
delete process.env.DEBUG;
|
|
delete process.env.EVOLVER_DEBUG;
|
|
const origEvolutionDir = process.env.EVOLUTION_DIR;
|
|
process.env.EVOLUTION_DIR = '/dev/null/selfpr-nested';
|
|
|
|
const origWrite = process.stderr.write;
|
|
let captured = '';
|
|
process.stderr.write = function (chunk) {
|
|
captured += String(chunk);
|
|
return true;
|
|
};
|
|
|
|
try {
|
|
assert.doesNotThrow(() => writeState({ lastPRAt: null, recentDiffHashes: [] }));
|
|
assert.equal(captured, '', 'no stderr output without DEBUG');
|
|
} finally {
|
|
process.stderr.write = origWrite;
|
|
if (origEvolutionDir !== undefined) process.env.EVOLUTION_DIR = origEvolutionDir;
|
|
else delete process.env.EVOLUTION_DIR;
|
|
if (origDebug !== undefined) process.env.DEBUG = origDebug;
|
|
if (origEvolverDebug !== undefined) process.env.EVOLVER_DEBUG = origEvolverDebug;
|
|
}
|
|
});
|
|
|
|
it('writeState writes a diagnostic line to stderr when DEBUG is set', () => {
|
|
const origDebug = process.env.DEBUG;
|
|
const origEvolutionDir = process.env.EVOLUTION_DIR;
|
|
process.env.DEBUG = '1';
|
|
// Use a regular file as the parent so mkdirSync fails on all platforms
|
|
// (Unix /dev/null trick does not work on Windows where it resolves to a
|
|
// writable path and the write succeeds, suppressing the debug output).
|
|
const blocker = path.join(os.tmpdir(), 'selfpr-test-blocker-' + Date.now() + '.txt');
|
|
fs.writeFileSync(blocker, 'block');
|
|
process.env.EVOLUTION_DIR = path.join(blocker, 'nested');
|
|
|
|
const origWrite = process.stderr.write;
|
|
let captured = '';
|
|
process.stderr.write = function (chunk) {
|
|
captured += String(chunk);
|
|
return true;
|
|
};
|
|
|
|
try {
|
|
assert.doesNotThrow(() => writeState({ lastPRAt: null, recentDiffHashes: [] }));
|
|
assert.ok(captured.includes('selfPR.writeState failed'), 'diagnostic line should be emitted under DEBUG');
|
|
} finally {
|
|
process.stderr.write = origWrite;
|
|
try { fs.rmSync(blocker, { force: true }); } catch (_) {}
|
|
if (origDebug !== undefined) process.env.DEBUG = origDebug;
|
|
else delete process.env.DEBUG;
|
|
if (origEvolutionDir !== undefined) process.env.EVOLUTION_DIR = origEvolutionDir;
|
|
else delete process.env.EVOLUTION_DIR;
|
|
}
|
|
});
|
|
});
|
|
|
|
// --- computeDiffHash ---
|
|
|
|
describe('computeDiffHash', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selfpr-diffhash-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
|
});
|
|
|
|
it('produces consistent hash for same content', () => {
|
|
const subDir = path.join(tmpDir, 'src', 'gep');
|
|
fs.mkdirSync(subDir, { recursive: true });
|
|
fs.writeFileSync(path.join(subDir, 'test.js'), 'console.log("hello");');
|
|
|
|
const h1 = computeDiffHash(['src/gep/test.js'], tmpDir);
|
|
const h2 = computeDiffHash(['src/gep/test.js'], tmpDir);
|
|
assert.equal(h1, h2);
|
|
});
|
|
|
|
it('produces different hash for different content', () => {
|
|
const subDir = path.join(tmpDir, 'src', 'gep');
|
|
fs.mkdirSync(subDir, { recursive: true });
|
|
fs.writeFileSync(path.join(subDir, 'a.js'), 'const a = 1;');
|
|
fs.writeFileSync(path.join(subDir, 'b.js'), 'const b = 2;');
|
|
|
|
const h1 = computeDiffHash(['src/gep/a.js'], tmpDir);
|
|
const h2 = computeDiffHash(['src/gep/b.js'], tmpDir);
|
|
assert.notEqual(h1, h2);
|
|
});
|
|
|
|
it('returns 16-char hex string', () => {
|
|
const h = computeDiffHash([], tmpDir);
|
|
assert.ok(/^[0-9a-f]{16}$/.test(h), 'hash should be 16 hex chars');
|
|
});
|
|
});
|
|
|
|
// --- policyCheck integration: optimize category allowed with EVOLVE_ALLOW_SELF_MODIFY ---
|
|
|
|
describe('policyCheck allows optimize gene self-modify', () => {
|
|
let origAllow;
|
|
|
|
beforeEach(() => {
|
|
origAllow = process.env.EVOLVE_ALLOW_SELF_MODIFY;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (origAllow !== undefined) {
|
|
process.env.EVOLVE_ALLOW_SELF_MODIFY = origAllow;
|
|
} else {
|
|
delete process.env.EVOLVE_ALLOW_SELF_MODIFY;
|
|
}
|
|
});
|
|
|
|
it('allows optimize gene to modify skills/evolver/ when flag is set', () => {
|
|
process.env.EVOLVE_ALLOW_SELF_MODIFY = 'true';
|
|
const { checkConstraints } = require('../src/gep/policyCheck');
|
|
const result = checkConstraints({
|
|
gene: { type: 'Gene', id: 'gene_opt', category: 'optimize', constraints: { max_files: 10 } },
|
|
blast: { files: 1, lines: 20, changed_files: ['skills/evolver/src/gep/signals.js'], all_changed_files: ['skills/evolver/src/gep/signals.js'] },
|
|
repoRoot: '/tmp/fake',
|
|
});
|
|
assert.equal(result.ok, true, 'should not have violations');
|
|
assert.ok(result.warnings.some(w => w.includes('self_modify_evolver_optimize')), 'should have self_modify_evolver_optimize warning');
|
|
});
|
|
|
|
it('blocks optimize gene self-modify when flag is not set', () => {
|
|
delete process.env.EVOLVE_ALLOW_SELF_MODIFY;
|
|
const { checkConstraints } = require('../src/gep/policyCheck');
|
|
const result = checkConstraints({
|
|
gene: { type: 'Gene', id: 'gene_opt', category: 'optimize', constraints: { max_files: 10 } },
|
|
blast: { files: 1, lines: 20, changed_files: ['skills/evolver/src/gep/signals.js'], all_changed_files: ['skills/evolver/src/gep/signals.js'] },
|
|
repoRoot: '/tmp/fake',
|
|
});
|
|
assert.ok(result.violations.some(v => v.includes('critical_path_modified')), 'should have critical_path_modified violation');
|
|
});
|
|
});
|
|
|
|
console.log('[selfPR.test.js] All assertions passed.');
|