Files
evomap__evolver/test/selfPR.test.js
T
evolver-publish 4854a3b9b9 Release v1.87.3
2026-06-01 15:20:47 +00:00

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.');