Recovered from crashed session (Node OOM). Repo contains full P0-P6 scaffold: plugin.json/marketplace.json, AGENTS.md, ADRs 0001-0006, lh CLI (init/index/graph/lane/run/memory/host/report/doctor), 10 .github/agents, 12 CLI skills, instructions, context7 mcp.json, and unit/e2e test suite. Fixed: run.mjs read --in-tokens/--out-tokens but tests and CLI docs use --input-tokens/--output-tokens, so telemetry totals were always 0. Now accepts both forms. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
159 lines
5.5 KiB
JavaScript
159 lines
5.5 KiB
JavaScript
import { test, describe, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { defaultConfig, validateConfig, modelForRole, MEMORY_SHARDS, RALPH_EXIT_CRITERIA } from '../src/lib/config.mjs';
|
|
import { globsIntersect } from '../src/lib/lane.mjs';
|
|
import { scanSecrets } from '../src/lib/memory.mjs';
|
|
import { summarize } from '../src/lib/telemetry.mjs';
|
|
import { globMatch } from '../src/lib/repomap.mjs';
|
|
import { cleanup } from './helpers.mjs';
|
|
|
|
after(cleanup);
|
|
|
|
describe('config', () => {
|
|
test('default config is valid', () => {
|
|
assert.deepEqual(validateConfig(defaultConfig()), []);
|
|
});
|
|
|
|
test('rejects a bad isolation backend', () => {
|
|
const c = defaultConfig();
|
|
c.isolation.backend = 'kubernetes';
|
|
assert.ok(validateConfig(c).some((p) => p.includes('isolation.backend')));
|
|
});
|
|
|
|
test('rejects an unknown ralph exit criterion', () => {
|
|
const c = defaultConfig();
|
|
c.ralph.exitCriteria = ['vibes'];
|
|
assert.ok(validateConfig(c).some((p) => p.includes('vibes')));
|
|
});
|
|
|
|
test('rejects a non-positive memory budget', () => {
|
|
const c = defaultConfig();
|
|
c.memory.tokenBudget = 0;
|
|
assert.ok(validateConfig(c).some((p) => p.includes('memory.tokenBudget')));
|
|
});
|
|
|
|
test('cheap roles resolve to the cheap tier model', () => {
|
|
const c = defaultConfig();
|
|
const cheap = modelForRole(c, 'scout');
|
|
assert.equal(cheap, modelForRole(c, 'verifier'));
|
|
assert.notEqual(cheap, modelForRole(c, 'conductor'));
|
|
});
|
|
|
|
test('a role override is honoured', () => {
|
|
const c = defaultConfig();
|
|
c.models.roles.scout = 'strong';
|
|
assert.equal(modelForRole(c, 'scout'), modelForRole(c, 'conductor'));
|
|
});
|
|
|
|
test('exposes the five memory shards plus seed', () => {
|
|
for (const s of ['failures', 'corrections', 'insights', 'conventions', 'quirks']) {
|
|
assert.ok(MEMORY_SHARDS.includes(s), `missing shard ${s}`);
|
|
}
|
|
assert.ok(RALPH_EXIT_CRITERIA.length >= 5);
|
|
});
|
|
});
|
|
|
|
describe('lane scope safety', () => {
|
|
// The load-bearing invariant: two write lanes must never share a file.
|
|
const overlapping = [
|
|
['src/**', 'src/a/deep/**'],
|
|
['src/a/**', 'src/a/**'],
|
|
['**', 'anything/at/all.js'],
|
|
['src/a.js', 'src/a.js'],
|
|
['src/*.js', 'src/a.js'],
|
|
['lib/**/*.ts', 'lib/deep/nested/x.ts'],
|
|
];
|
|
for (const [a, b] of overlapping) {
|
|
test(`rejects overlap: ${a} vs ${b}`, () => {
|
|
assert.equal(globsIntersect(a, b), true, `${a} should intersect ${b}`);
|
|
assert.equal(globsIntersect(b, a), true, 'intersection must be symmetric');
|
|
});
|
|
}
|
|
|
|
const disjoint = [
|
|
['src/a/**', 'src/b/**'],
|
|
['src/a.js', 'src/b.js'],
|
|
['docs/**', 'src/**'],
|
|
['lib/**/*.ts', 'lib/x.js'],
|
|
];
|
|
for (const [a, b] of disjoint) {
|
|
test(`allows disjoint: ${a} vs ${b}`, () => {
|
|
assert.equal(globsIntersect(a, b), false, `${a} should not intersect ${b}`);
|
|
assert.equal(globsIntersect(b, a), false, 'disjointness must be symmetric');
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('glob matching', () => {
|
|
test('** crosses directory boundaries', () => {
|
|
assert.equal(globMatch('src/**', 'src/a/b/c.js'), true);
|
|
});
|
|
test('* does not cross directory boundaries', () => {
|
|
assert.equal(globMatch('src/*.js', 'src/a/b.js'), false);
|
|
});
|
|
test('non-matching prefix fails', () => {
|
|
assert.equal(globMatch('src/**', 'docs/a.js'), false);
|
|
});
|
|
});
|
|
|
|
describe('secret scanning', () => {
|
|
const secrets = [
|
|
['github token', 'token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'],
|
|
['openai key', 'sk-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrst'],
|
|
['private key', '-----BEGIN RSA PRIVATE KEY-----'],
|
|
['aws key', 'AKIAIOSFODNN7EXAMPLE'],
|
|
];
|
|
for (const [name, text] of secrets) {
|
|
test(`detects a ${name}`, () => {
|
|
const hits = scanSecrets(text);
|
|
assert.ok(hits.length > 0, `expected a hit for ${name}`);
|
|
});
|
|
}
|
|
|
|
test('does not fire on ordinary prose', () => {
|
|
assert.deepEqual(scanSecrets('The build failed because the test timed out after 30s.'), []);
|
|
});
|
|
|
|
test('never echoes the raw secret back', () => {
|
|
const raw = 'ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
for (const hit of scanSecrets(`token ${raw}`)) {
|
|
assert.ok(!JSON.stringify(hit).includes(raw), 'secret must be redacted in the finding');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('telemetry summarize', () => {
|
|
test('sums token usage across events', () => {
|
|
const s = summarize([
|
|
{ ts: '2026-01-01T00:00:00.000Z', type: 'run.start' },
|
|
{ ts: '2026-01-01T00:00:10.000Z', type: 'agent.end', 'gen_ai.usage.input_tokens': 100, 'gen_ai.usage.output_tokens': 50 },
|
|
{ ts: '2026-01-01T00:01:00.000Z', type: 'agent.end', 'gen_ai.usage.input_tokens': 200, 'gen_ai.usage.output_tokens': 25 },
|
|
]);
|
|
assert.equal(s.totals.inputTokens, 300);
|
|
assert.equal(s.totals.outputTokens, 75);
|
|
assert.equal(s.totals.totalTokens, 375);
|
|
assert.equal(s.totals.wallMs, 60_000);
|
|
});
|
|
|
|
test('tolerates empty input', () => {
|
|
const s = summarize([]);
|
|
assert.equal(s.totals.totalTokens, 0);
|
|
assert.equal(s.totals.wallMs, 0);
|
|
});
|
|
|
|
test('ignores malformed timestamps rather than producing NaN', () => {
|
|
const s = summarize([
|
|
{ ts: 'not-a-date', type: 'run.start' },
|
|
{ ts: '2026-01-01T00:00:00.000Z', type: 'agent.end', 'gen_ai.usage.input_tokens': 10 },
|
|
]);
|
|
assert.ok(Number.isFinite(s.totals.wallMs));
|
|
assert.equal(s.totals.inputTokens, 10);
|
|
});
|
|
|
|
test('ignores non-numeric token fields', () => {
|
|
const s = summarize([{ ts: '2026-01-01T00:00:00.000Z', 'gen_ai.usage.input_tokens': 'lots' }]);
|
|
assert.equal(s.totals.inputTokens, 0);
|
|
});
|
|
});
|