Files
Regolith/skills-engine/__tests__/update.test.ts
gavrielc 51788de3b9 Skills engine v0.1 + multi-channel infrastructure (#307)
* refactor: multi-channel infrastructure with explicit channel/is_group tracking

- Add channels[] array and findChannel() routing in index.ts, replacing
  hardcoded whatsapp.* calls with channel-agnostic callbacks
- Add channel TEXT and is_group INTEGER columns to chats table with
  COALESCE upsert to protect existing values from null overwrites
- is_group defaults to 0 (safe: unknown chats excluded from groups)
- WhatsApp passes explicit channel='whatsapp' and isGroup to onChatMetadata
- getAvailableGroups filters on is_group instead of JID pattern matching
- findChannel logs warnings instead of silently dropping unroutable JIDs
- Migration backfills channel/is_group from JID patterns for existing DBs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: skills engine v0.1 — deterministic skill packages with rerere resolution

Three-way merge engine for applying skill packages on top of a core
codebase. Skills declare which files they add/modify, and the engine
uses git merge-file for conflict detection with git rerere for
automatic resolution of previously-seen conflicts.

Key components:
- apply: three-way merge with backup/rollback safety net
- replay: clean-slate replay for uninstall and rebase
- update: core version updates with deletion detection
- rebase: bake applied skills into base (one-way)
- manifest: validation with path traversal protection
- resolution-cache: pre-computed rerere resolutions
- structured: npm deps, env vars, docker-compose merging
- CI: per-skill test matrix with conflict detection

151 unit tests covering merge, rerere, backup, replay, uninstall,
update, rebase, structured ops, and edge cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add Discord and Telegram skill packages

Skill packages for adding Discord and Telegram channels to NanoClaw.
Each package includes:
- Channel implementation (add/src/channels/)
- Three-way merge targets for index.ts, config.ts, routing.test.ts
- Intent docs explaining merge invariants
- Standalone integration tests
- manifest.yaml with dependency/conflict declarations

Applied via: npx tsx scripts/apply-skill.ts .claude/skills/add-discord
These are inert until applied — no runtime impact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* remove unused docs (skills-system-status, implementation-guide)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 01:55:00 +02:00

414 lines
13 KiB
TypeScript

import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { stringify } from 'yaml';
import { cleanup, createTempDir, initGitRepo, setupNanoclawDir } from './test-helpers.js';
// We need to mock process.cwd() since update.ts uses it
let tmpDir: string;
describe('update', () => {
beforeEach(() => {
tmpDir = createTempDir();
setupNanoclawDir(tmpDir);
initGitRepo(tmpDir);
vi.spyOn(process, 'cwd').mockReturnValue(tmpDir);
});
afterEach(() => {
vi.restoreAllMocks();
cleanup(tmpDir);
});
function writeStateFile(state: Record<string, unknown>): void {
const statePath = path.join(tmpDir, '.nanoclaw', 'state.yaml');
fs.writeFileSync(statePath, stringify(state), 'utf-8');
}
function createNewCoreDir(files: Record<string, string>): string {
const newCoreDir = path.join(tmpDir, 'new-core');
fs.mkdirSync(newCoreDir, { recursive: true });
for (const [relPath, content] of Object.entries(files)) {
const fullPath = path.join(newCoreDir, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
}
return newCoreDir;
}
describe('previewUpdate', () => {
it('detects new files in update', async () => {
writeStateFile({
skills_system_version: '0.1.0',
core_version: '1.0.0',
applied_skills: [],
});
const newCoreDir = createNewCoreDir({
'src/new-file.ts': 'export const x = 1;',
});
const { previewUpdate } = await import('../update.js');
const preview = previewUpdate(newCoreDir);
expect(preview.filesChanged).toContain('src/new-file.ts');
expect(preview.currentVersion).toBe('1.0.0');
});
it('detects changed files vs base', async () => {
const baseDir = path.join(tmpDir, '.nanoclaw', 'base');
fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
fs.writeFileSync(path.join(baseDir, 'src/index.ts'), 'original');
writeStateFile({
skills_system_version: '0.1.0',
core_version: '1.0.0',
applied_skills: [],
});
const newCoreDir = createNewCoreDir({
'src/index.ts': 'modified',
});
const { previewUpdate } = await import('../update.js');
const preview = previewUpdate(newCoreDir);
expect(preview.filesChanged).toContain('src/index.ts');
});
it('does not list unchanged files', async () => {
const baseDir = path.join(tmpDir, '.nanoclaw', 'base');
fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
fs.writeFileSync(path.join(baseDir, 'src/index.ts'), 'same content');
writeStateFile({
skills_system_version: '0.1.0',
core_version: '1.0.0',
applied_skills: [],
});
const newCoreDir = createNewCoreDir({
'src/index.ts': 'same content',
});
const { previewUpdate } = await import('../update.js');
const preview = previewUpdate(newCoreDir);
expect(preview.filesChanged).not.toContain('src/index.ts');
});
it('identifies conflict risk with applied skills', async () => {
const baseDir = path.join(tmpDir, '.nanoclaw', 'base');
fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
fs.writeFileSync(path.join(baseDir, 'src/index.ts'), 'original');
writeStateFile({
skills_system_version: '0.1.0',
core_version: '1.0.0',
applied_skills: [
{
name: 'telegram',
version: '1.0.0',
applied_at: new Date().toISOString(),
file_hashes: { 'src/index.ts': 'abc123' },
},
],
});
const newCoreDir = createNewCoreDir({
'src/index.ts': 'updated core',
});
const { previewUpdate } = await import('../update.js');
const preview = previewUpdate(newCoreDir);
expect(preview.conflictRisk).toContain('src/index.ts');
});
it('identifies custom patches at risk', async () => {
const baseDir = path.join(tmpDir, '.nanoclaw', 'base');
fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
fs.writeFileSync(path.join(baseDir, 'src/config.ts'), 'original');
writeStateFile({
skills_system_version: '0.1.0',
core_version: '1.0.0',
applied_skills: [],
custom_modifications: [
{
description: 'custom tweak',
applied_at: new Date().toISOString(),
files_modified: ['src/config.ts'],
patch_file: '.nanoclaw/custom/001-tweak.patch',
},
],
});
const newCoreDir = createNewCoreDir({
'src/config.ts': 'updated core config',
});
const { previewUpdate } = await import('../update.js');
const preview = previewUpdate(newCoreDir);
expect(preview.customPatchesAtRisk).toContain('src/config.ts');
});
it('reads version from package.json in new core', async () => {
writeStateFile({
skills_system_version: '0.1.0',
core_version: '1.0.0',
applied_skills: [],
});
const newCoreDir = createNewCoreDir({
'package.json': JSON.stringify({ version: '2.0.0' }),
});
const { previewUpdate } = await import('../update.js');
const preview = previewUpdate(newCoreDir);
expect(preview.newVersion).toBe('2.0.0');
});
it('detects files deleted in new core', async () => {
const baseDir = path.join(tmpDir, '.nanoclaw', 'base');
fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
fs.writeFileSync(path.join(baseDir, 'src/index.ts'), 'keep this');
fs.writeFileSync(path.join(baseDir, 'src/removed.ts'), 'delete this');
writeStateFile({
skills_system_version: '0.1.0',
core_version: '1.0.0',
applied_skills: [],
});
// New core only has index.ts — removed.ts is gone
const newCoreDir = createNewCoreDir({
'src/index.ts': 'keep this',
});
const { previewUpdate } = await import('../update.js');
const preview = previewUpdate(newCoreDir);
expect(preview.filesDeleted).toContain('src/removed.ts');
expect(preview.filesChanged).not.toContain('src/removed.ts');
});
});
describe('applyUpdate', () => {
it('rejects when customize session is active', async () => {
writeStateFile({
skills_system_version: '0.1.0',
core_version: '1.0.0',
applied_skills: [],
});
// Create the pending.yaml that indicates active customize
const customDir = path.join(tmpDir, '.nanoclaw', 'custom');
fs.mkdirSync(customDir, { recursive: true });
fs.writeFileSync(path.join(customDir, 'pending.yaml'), 'active: true');
const newCoreDir = createNewCoreDir({
'src/index.ts': 'new content',
});
const { applyUpdate } = await import('../update.js');
const result = await applyUpdate(newCoreDir);
expect(result.success).toBe(false);
expect(result.error).toContain('customize session');
});
it('copies new files that do not exist yet', async () => {
writeStateFile({
skills_system_version: '0.1.0',
core_version: '1.0.0',
applied_skills: [],
});
const newCoreDir = createNewCoreDir({
'src/brand-new.ts': 'export const fresh = true;',
});
const { applyUpdate } = await import('../update.js');
const result = await applyUpdate(newCoreDir);
expect(result.error).toBeUndefined();
expect(result.success).toBe(true);
expect(
fs.readFileSync(path.join(tmpDir, 'src/brand-new.ts'), 'utf-8'),
).toBe('export const fresh = true;');
});
it('performs clean three-way merge', async () => {
// Set up base
const baseDir = path.join(tmpDir, '.nanoclaw', 'base');
fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(baseDir, 'src/index.ts'),
'line 1\nline 2\nline 3\n',
);
// Current has user changes at the bottom
fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, 'src/index.ts'),
'line 1\nline 2\nline 3\nuser addition\n',
);
writeStateFile({
skills_system_version: '0.1.0',
core_version: '1.0.0',
applied_skills: [],
});
// New core changes at the top
const newCoreDir = createNewCoreDir({
'src/index.ts': 'core update\nline 1\nline 2\nline 3\n',
'package.json': JSON.stringify({ version: '2.0.0' }),
});
const { applyUpdate } = await import('../update.js');
const result = await applyUpdate(newCoreDir);
expect(result.success).toBe(true);
expect(result.newVersion).toBe('2.0.0');
const merged = fs.readFileSync(
path.join(tmpDir, 'src/index.ts'),
'utf-8',
);
expect(merged).toContain('core update');
expect(merged).toContain('user addition');
});
it('updates base directory after successful merge', async () => {
const baseDir = path.join(tmpDir, '.nanoclaw', 'base');
fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
fs.writeFileSync(path.join(baseDir, 'src/index.ts'), 'old base');
fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, 'src/index.ts'), 'old base');
writeStateFile({
skills_system_version: '0.1.0',
core_version: '1.0.0',
applied_skills: [],
});
const newCoreDir = createNewCoreDir({
'src/index.ts': 'new base content',
});
const { applyUpdate } = await import('../update.js');
await applyUpdate(newCoreDir);
const newBase = fs.readFileSync(
path.join(tmpDir, '.nanoclaw', 'base', 'src/index.ts'),
'utf-8',
);
expect(newBase).toBe('new base content');
});
it('updates core_version in state after success', async () => {
writeStateFile({
skills_system_version: '0.1.0',
core_version: '1.0.0',
applied_skills: [],
});
const newCoreDir = createNewCoreDir({
'package.json': JSON.stringify({ version: '2.0.0' }),
});
const { applyUpdate } = await import('../update.js');
const result = await applyUpdate(newCoreDir);
expect(result.success).toBe(true);
expect(result.previousVersion).toBe('1.0.0');
expect(result.newVersion).toBe('2.0.0');
// Verify state file was updated
const { readState } = await import('../state.js');
const state = readState();
expect(state.core_version).toBe('2.0.0');
});
it('restores backup on merge conflict', async () => {
const baseDir = path.join(tmpDir, '.nanoclaw', 'base');
fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(baseDir, 'src/index.ts'),
'line 1\nline 2\nline 3\n',
);
// Current has conflicting change on same line
fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, 'src/index.ts'),
'line 1\nuser changed line 2\nline 3\n',
);
writeStateFile({
skills_system_version: '0.1.0',
core_version: '1.0.0',
applied_skills: [],
});
// New core also changes line 2 — guaranteed conflict
const newCoreDir = createNewCoreDir({
'src/index.ts': 'line 1\ncore changed line 2\nline 3\n',
});
const { applyUpdate } = await import('../update.js');
const result = await applyUpdate(newCoreDir);
expect(result.success).toBe(false);
expect(result.mergeConflicts).toContain('src/index.ts');
expect(result.backupPending).toBe(true);
// File should have conflict markers (backup preserved, not restored)
const content = fs.readFileSync(
path.join(tmpDir, 'src/index.ts'),
'utf-8',
);
expect(content).toContain('<<<<<<<');
expect(content).toContain('>>>>>>>');
});
it('removes files deleted in new core', async () => {
const baseDir = path.join(tmpDir, '.nanoclaw', 'base');
fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
fs.writeFileSync(path.join(baseDir, 'src/index.ts'), 'keep');
fs.writeFileSync(path.join(baseDir, 'src/removed.ts'), 'old content');
// Working tree has both files
fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, 'src/index.ts'), 'keep');
fs.writeFileSync(path.join(tmpDir, 'src/removed.ts'), 'old content');
writeStateFile({
skills_system_version: '0.1.0',
core_version: '1.0.0',
applied_skills: [],
});
// New core only has index.ts
const newCoreDir = createNewCoreDir({
'src/index.ts': 'keep',
});
const { applyUpdate } = await import('../update.js');
const result = await applyUpdate(newCoreDir);
expect(result.success).toBe(true);
expect(fs.existsSync(path.join(tmpDir, 'src/index.ts'))).toBe(true);
expect(fs.existsSync(path.join(tmpDir, 'src/removed.ts'))).toBe(false);
});
});
});