Merge branch 'main' into feat/auto-update-command

This commit is contained in:
Affaan Mustafa
2026-04-14 19:29:36 -07:00
committed by GitHub
62 changed files with 4493 additions and 107 deletions

0
tests/__init__.py Normal file
View File

4
tests/conftest.py Normal file
View File

@@ -0,0 +1,4 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))

View File

@@ -0,0 +1,437 @@
/**
* Tests for scripts/hooks/gateguard-fact-force.js via run-with-flags.js
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const runner = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'run-with-flags.js');
const externalStateDir = process.env.GATEGUARD_STATE_DIR;
const tmpRoot = process.env.TMPDIR || process.env.TEMP || process.env.TMP || '/tmp';
const stateDir = externalStateDir || fs.mkdtempSync(path.join(tmpRoot, 'gateguard-test-'));
// Use a fixed session ID so test process and spawned hook process share the same state file
const TEST_SESSION_ID = 'gateguard-test-session';
const stateFile = path.join(stateDir, `state-${TEST_SESSION_ID}.json`);
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function clearState() {
try {
if (fs.existsSync(stateFile)) {
fs.unlinkSync(stateFile);
}
} catch (err) {
console.error(` [clearState] failed to remove ${stateFile}: ${err.message}`);
}
}
function writeExpiredState() {
try {
fs.mkdirSync(stateDir, { recursive: true });
const expired = {
checked: ['some_file.js', '__bash_session__'],
last_active: Date.now() - (31 * 60 * 1000) // 31 minutes ago
};
fs.writeFileSync(stateFile, JSON.stringify(expired), 'utf8');
} catch (_) { /* ignore */ }
}
function writeState(state) {
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(stateFile, JSON.stringify(state), 'utf8');
}
function runHook(input, env = {}) {
const rawInput = typeof input === 'string' ? input : JSON.stringify(input);
const result = spawnSync('node', [
runner,
'pre:edit-write:gateguard-fact-force',
'scripts/hooks/gateguard-fact-force.js',
'standard,strict'
], {
input: rawInput,
encoding: 'utf8',
env: {
...process.env,
ECC_HOOK_PROFILE: 'standard',
GATEGUARD_STATE_DIR: stateDir,
CLAUDE_SESSION_ID: TEST_SESSION_ID,
...env
},
timeout: 15000,
stdio: ['pipe', 'pipe', 'pipe']
});
return {
code: Number.isInteger(result.status) ? result.status : 1,
stdout: result.stdout || '',
stderr: result.stderr || ''
};
}
function runBashHook(input, env = {}) {
const rawInput = typeof input === 'string' ? input : JSON.stringify(input);
const result = spawnSync('node', [
runner,
'pre:bash:gateguard-fact-force',
'scripts/hooks/gateguard-fact-force.js',
'standard,strict'
], {
input: rawInput,
encoding: 'utf8',
env: {
...process.env,
ECC_HOOK_PROFILE: 'standard',
GATEGUARD_STATE_DIR: stateDir,
CLAUDE_SESSION_ID: TEST_SESSION_ID,
...env
},
timeout: 15000,
stdio: ['pipe', 'pipe', 'pipe']
});
return {
code: Number.isInteger(result.status) ? result.status : 1,
stdout: result.stdout || '',
stderr: result.stderr || ''
};
}
function parseOutput(stdout) {
try {
return JSON.parse(stdout);
} catch (_) {
return null;
}
}
function runTests() {
console.log('\n=== Testing gateguard-fact-force ===\n');
let passed = 0;
let failed = 0;
// --- Test 1: denies first Edit per file ---
clearState();
if (test('denies first Edit per file with fact-forcing message', () => {
const input = {
tool_name: 'Edit',
tool_input: { file_path: '/src/app.js', old_string: 'foo', new_string: 'bar' }
};
const result = runHook(input);
assert.strictEqual(result.code, 0, 'exit code should be 0');
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce JSON output');
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('Fact-Forcing Gate'));
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('import/require'));
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('/src/app.js'));
})) passed++; else failed++;
// --- Test 2: allows second Edit on same file ---
if (test('allows second Edit on same file (gate already passed)', () => {
const input = {
tool_name: 'Edit',
tool_input: { file_path: '/src/app.js', old_string: 'foo', new_string: 'bar' }
};
const result = runHook(input);
assert.strictEqual(result.code, 0, 'exit code should be 0');
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
// When allowed, the hook passes through the raw input (no hookSpecificOutput)
// OR if hookSpecificOutput exists, it must not be deny
if (output.hookSpecificOutput) {
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
'should not deny second edit on same file');
} else {
// Pass-through: output matches original input (allow)
assert.strictEqual(output.tool_name, 'Edit', 'pass-through should preserve input');
}
})) passed++; else failed++;
// --- Test 3: denies first Write per file ---
clearState();
if (test('denies first Write per file with fact-forcing message', () => {
const input = {
tool_name: 'Write',
tool_input: { file_path: '/src/new-file.js', content: 'console.log("hello")' }
};
const result = runHook(input);
assert.strictEqual(result.code, 0, 'exit code should be 0');
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce JSON output');
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('creating'));
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('call this new file'));
})) passed++; else failed++;
// --- Test 4: denies destructive Bash, allows retry ---
clearState();
if (test('denies destructive Bash commands, allows retry after facts presented', () => {
const input = {
tool_name: 'Bash',
tool_input: { command: 'rm -rf /important/data' }
};
// First call: should deny
const result1 = runBashHook(input);
assert.strictEqual(result1.code, 0, 'first call exit code should be 0');
const output1 = parseOutput(result1.stdout);
assert.ok(output1, 'first call should produce JSON output');
assert.strictEqual(output1.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output1.hookSpecificOutput.permissionDecisionReason.includes('Destructive'));
assert.ok(output1.hookSpecificOutput.permissionDecisionReason.includes('rollback'));
// Second call (retry after facts presented): should allow
const result2 = runBashHook(input);
assert.strictEqual(result2.code, 0, 'second call exit code should be 0');
const output2 = parseOutput(result2.stdout);
assert.ok(output2, 'second call should produce valid JSON output');
if (output2.hookSpecificOutput) {
assert.notStrictEqual(output2.hookSpecificOutput.permissionDecision, 'deny',
'should not deny destructive bash retry after facts presented');
} else {
assert.strictEqual(output2.tool_name, 'Bash', 'pass-through should preserve input');
}
})) passed++; else failed++;
// --- Test 5: denies first routine Bash, allows second ---
clearState();
if (test('denies first routine Bash, allows second', () => {
const input = {
tool_name: 'Bash',
tool_input: { command: 'ls -la' }
};
// First call: should deny
const result1 = runBashHook(input);
assert.strictEqual(result1.code, 0, 'first call exit code should be 0');
const output1 = parseOutput(result1.stdout);
assert.ok(output1, 'first call should produce JSON output');
assert.strictEqual(output1.hookSpecificOutput.permissionDecision, 'deny');
// Second call: should allow
const result2 = runBashHook(input);
assert.strictEqual(result2.code, 0, 'second call exit code should be 0');
const output2 = parseOutput(result2.stdout);
assert.ok(output2, 'second call should produce valid JSON output');
if (output2.hookSpecificOutput) {
assert.notStrictEqual(output2.hookSpecificOutput.permissionDecision, 'deny',
'should not deny second routine bash');
} else {
assert.strictEqual(output2.tool_name, 'Bash', 'pass-through should preserve input');
}
})) passed++; else failed++;
// --- Test 6: session state resets after timeout ---
if (test('session state resets after 30-minute timeout', () => {
writeExpiredState();
const input = {
tool_name: 'Edit',
tool_input: { file_path: 'some_file.js', old_string: 'a', new_string: 'b' }
};
const result = runHook(input);
assert.strictEqual(result.code, 0, 'exit code should be 0');
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce JSON output after expired state');
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
'should deny again after session timeout (state was reset)');
})) passed++; else failed++;
// --- Test 7: allows unknown tool names ---
clearState();
if (test('allows unknown tool names through', () => {
const input = {
tool_name: 'Read',
tool_input: { file_path: '/src/app.js' }
};
const result = runHook(input);
assert.strictEqual(result.code, 0, 'exit code should be 0');
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
if (output.hookSpecificOutput) {
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
'should not deny unknown tool');
} else {
assert.strictEqual(output.tool_name, 'Read', 'pass-through should preserve input');
}
})) passed++; else failed++;
// --- Test 8: sanitizes file paths with newlines ---
clearState();
if (test('sanitizes file paths containing newlines', () => {
const input = {
tool_name: 'Edit',
tool_input: { file_path: '/src/app.js\ninjected content', old_string: 'a', new_string: 'b' }
};
const result = runHook(input);
assert.strictEqual(result.code, 0, 'exit code should be 0');
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce JSON output');
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
const reason = output.hookSpecificOutput.permissionDecisionReason;
// The file path portion of the reason must not contain any raw newlines
// (sanitizePath replaces \n and \r with spaces)
const pathLine = reason.split('\n').find(l => l.includes('/src/app.js'));
assert.ok(pathLine, 'reason should mention the file path');
assert.ok(!pathLine.includes('\n'), 'file path line must not contain raw newlines');
assert.ok(!reason.includes('/src/app.js\n'), 'newline after file path should be sanitized');
assert.ok(!reason.includes('\ninjected'), 'injected content must not appear on its own line');
})) passed++; else failed++;
// --- Test 9: respects ECC_DISABLED_HOOKS ---
clearState();
if (test('respects ECC_DISABLED_HOOKS (skips when disabled)', () => {
const input = {
tool_name: 'Edit',
tool_input: { file_path: '/src/disabled.js', old_string: 'a', new_string: 'b' }
};
const result = runHook(input, {
ECC_DISABLED_HOOKS: 'pre:edit-write:gateguard-fact-force'
});
assert.strictEqual(result.code, 0, 'exit code should be 0');
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
if (output.hookSpecificOutput) {
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
'should not deny when hook is disabled');
} else {
// When disabled, hook passes through raw input
assert.strictEqual(output.tool_name, 'Edit', 'pass-through should preserve input');
}
})) passed++; else failed++;
// --- Test 10: MultiEdit gates first unchecked file ---
clearState();
if (test('denies first MultiEdit with unchecked file', () => {
const input = {
tool_name: 'MultiEdit',
tool_input: {
edits: [
{ file_path: '/src/multi-a.js', old_string: 'a', new_string: 'b' },
{ file_path: '/src/multi-b.js', old_string: 'c', new_string: 'd' }
]
}
};
const result = runHook(input);
assert.strictEqual(result.code, 0, 'exit code should be 0');
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce JSON output');
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('Fact-Forcing Gate'));
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('/src/multi-a.js'));
})) passed++; else failed++;
// --- Test 11: MultiEdit allows after all files gated ---
if (test('allows MultiEdit after all files gated', () => {
// multi-a.js was gated in test 10; gate multi-b.js
const input2 = {
tool_name: 'MultiEdit',
tool_input: { edits: [{ file_path: '/src/multi-b.js', old_string: 'c', new_string: 'd' }] }
};
runHook(input2); // gates multi-b.js
// Now both files are gated — retry should allow
const input3 = {
tool_name: 'MultiEdit',
tool_input: {
edits: [
{ file_path: '/src/multi-a.js', old_string: 'a', new_string: 'b' },
{ file_path: '/src/multi-b.js', old_string: 'c', new_string: 'd' }
]
}
};
const result3 = runHook(input3);
const output3 = parseOutput(result3.stdout);
assert.ok(output3, 'should produce valid JSON');
if (output3.hookSpecificOutput) {
assert.notStrictEqual(output3.hookSpecificOutput.permissionDecision, 'deny',
'should allow MultiEdit after all files gated');
}
})) passed++; else failed++;
// --- Test 12: reads refresh active session state ---
clearState();
if (test('touches last_active on read so active sessions do not age out', () => {
const staleButActive = Date.now() - (29 * 60 * 1000);
writeState({
checked: ['/src/keep-alive.js'],
last_active: staleButActive
});
const before = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
assert.strictEqual(before.last_active, staleButActive, 'seed state should use the expected timestamp');
const result = runHook({
tool_name: 'Edit',
tool_input: { file_path: '/src/keep-alive.js', old_string: 'a', new_string: 'b' }
});
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
if (output.hookSpecificOutput) {
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
'already-checked file should still be allowed');
}
const after = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
assert.ok(after.last_active > staleButActive, 'successful reads should refresh last_active');
})) passed++; else failed++;
// --- Test 13: pruning preserves routine bash gate marker ---
clearState();
if (test('preserves __bash_session__ when pruning oversized state', () => {
const checked = ['__bash_session__'];
for (let i = 0; i < 80; i++) checked.push(`__destructive__${i}`);
for (let i = 0; i < 700; i++) checked.push(`/src/file-${i}.js`);
writeState({ checked, last_active: Date.now() });
runHook({
tool_name: 'Edit',
tool_input: { file_path: '/src/newly-gated.js', old_string: 'a', new_string: 'b' }
});
const result = runBashHook({
tool_name: 'Bash',
tool_input: { command: 'pwd' }
});
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
if (output.hookSpecificOutput) {
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
'routine bash marker should survive pruning');
}
const persisted = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
assert.ok(persisted.checked.includes('__bash_session__'), 'pruned state should retain __bash_session__');
assert.ok(persisted.checked.length <= 500, 'pruned state should still honor the checked-entry cap');
})) passed++; else failed++;
// Cleanup only the temp directory created by this test file.
if (!externalStateDir) {
try {
if (fs.existsSync(stateDir)) {
fs.rmSync(stateDir, { recursive: true, force: true });
}
} catch (err) {
console.error(` [cleanup] failed to remove ${stateDir}: ${err.message}`);
}
}
console.log(`\n ${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();

View File

@@ -6,6 +6,8 @@
const assert = require('assert');
const fs = require('fs');
const http = require('http');
const https = require('https');
const os = require('os');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
@@ -109,6 +111,39 @@ function waitForFile(filePath, timeoutMs = 5000) {
}
throw new Error(`Timed out waiting for ${filePath}`);
}
function waitForHttpReady(urlString, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
const { protocol } = new URL(urlString);
const client = protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const attempt = () => {
const req = client.request(urlString, { method: 'GET' }, res => {
res.resume();
resolve();
});
req.setTimeout(250, () => {
req.destroy(new Error('timeout'));
});
req.on('error', error => {
if (Date.now() >= deadline) {
reject(new Error(`Timed out waiting for ${urlString}: ${error.message}`));
return;
}
setTimeout(attempt, 25);
});
req.end();
};
attempt();
});
}
async function runTests() {
console.log('\n=== Testing mcp-health-check.js ===\n');
@@ -329,6 +364,7 @@ async function runTests() {
try {
const port = waitForFile(portFile).trim();
await waitForHttpReady(`http://127.0.0.1:${port}/mcp`);
writeConfig(configPath, {
mcpServers: {
@@ -391,6 +427,7 @@ async function runTests() {
try {
const port = waitForFile(portFile).trim();
await waitForHttpReady(`http://127.0.0.1:${port}/mcp`);
writeConfig(configPath, {
mcpServers: {

View File

@@ -116,10 +116,19 @@ function runTests() {
assert.ok(plan.operations.length > 0, 'Should include scaffold operations');
assert.ok(
plan.operations.some(operation => (
operation.sourceRelativePath === '.cursor'
&& operation.strategy === 'sync-root-children'
operation.sourceRelativePath === '.cursor/hooks.json'
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks.json')
&& operation.strategy === 'preserve-relative-path'
)),
'Should flatten the native cursor root'
'Should preserve non-rule Cursor platform files'
);
assert.ok(
plan.operations.some(operation => (
operation.sourceRelativePath === 'rules/common/agents.md'
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.mdc')
&& operation.strategy === 'flatten-copy'
)),
'Should produce Cursor .mdc rules while preferring rules-core over duplicate platform copies'
);
})) passed++; else failed++;

View File

@@ -90,20 +90,22 @@ function runTests() {
assert.strictEqual(plan.targetRoot, path.join(projectRoot, '.cursor'));
assert.strictEqual(plan.installStatePath, path.join(projectRoot, '.cursor', 'ecc-install-state.json'));
const flattened = plan.operations.find(operation => operation.sourceRelativePath === '.cursor');
const hooksJson = plan.operations.find(operation => (
normalizedRelativePath(operation.sourceRelativePath) === '.cursor/hooks.json'
));
const preserved = plan.operations.find(operation => (
normalizedRelativePath(operation.sourceRelativePath) === 'rules/common/coding-style.md'
));
assert.ok(flattened, 'Should include .cursor scaffold operation');
assert.strictEqual(flattened.strategy, 'sync-root-children');
assert.strictEqual(flattened.destinationPath, path.join(projectRoot, '.cursor'));
assert.ok(hooksJson, 'Should preserve non-rule Cursor platform config files');
assert.strictEqual(hooksJson.strategy, 'preserve-relative-path');
assert.strictEqual(hooksJson.destinationPath, path.join(projectRoot, '.cursor', 'hooks.json'));
assert.ok(preserved, 'Should include flattened rules scaffold operations');
assert.strictEqual(preserved.strategy, 'flatten-copy');
assert.strictEqual(
preserved.destinationPath,
path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.md')
path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.mdc')
);
})) passed++; else failed++;
@@ -126,16 +128,16 @@ function runTests() {
assert.ok(
plan.operations.some(operation => (
normalizedRelativePath(operation.sourceRelativePath) === 'rules/common/coding-style.md'
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.md')
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.mdc')
)),
'Should flatten common rules into namespaced files'
'Should flatten common rules into namespaced .mdc files'
);
assert.ok(
plan.operations.some(operation => (
normalizedRelativePath(operation.sourceRelativePath) === 'rules/typescript/testing.md'
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'typescript-testing.md')
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'typescript-testing.mdc')
)),
'Should flatten language rules into namespaced files'
'Should flatten language rules into namespaced .mdc files'
);
assert.ok(
!plan.operations.some(operation => (
@@ -143,6 +145,132 @@ function runTests() {
)),
'Should not preserve nested rule directories for cursor installs'
);
assert.ok(
!plan.operations.some(operation => (
operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.md')
)),
'Should not emit .md Cursor rule files'
);
assert.ok(
!plan.operations.some(operation => (
normalizedRelativePath(operation.sourceRelativePath) === 'rules/README.md'
)),
'Should not install Cursor README docs as runtime rule files'
);
assert.ok(
!plan.operations.some(operation => (
normalizedRelativePath(operation.sourceRelativePath) === 'rules/zh/README.md'
)),
'Should not flatten localized README docs into Cursor rule files'
);
})) passed++; else failed++;
if (test('plans cursor platform rule files as .mdc and excludes rule README docs', () => {
const repoRoot = path.join(__dirname, '..', '..');
const projectRoot = '/workspace/app';
const plan = planInstallTargetScaffold({
target: 'cursor',
repoRoot,
projectRoot,
modules: [
{
id: 'platform-configs',
paths: ['.cursor'],
},
],
});
assert.ok(
plan.operations.some(operation => (
normalizedRelativePath(operation.sourceRelativePath) === '.cursor/rules/common-agents.md'
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.mdc')
)),
'Should rename Cursor platform rule files to .mdc'
);
assert.ok(
!plan.operations.some(operation => (
operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.md')
)),
'Should not preserve .md Cursor platform rule files'
);
assert.ok(
plan.operations.some(operation => (
normalizedRelativePath(operation.sourceRelativePath) === '.cursor/hooks.json'
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks.json')
)),
'Should preserve non-rule Cursor platform config files'
);
assert.ok(
!plan.operations.some(operation => (
operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'README.mdc')
)),
'Should not emit Cursor rule README docs as .mdc files'
);
})) passed++; else failed++;
if (test('deduplicates cursor rule destinations when rules-core and platform-configs overlap', () => {
const repoRoot = path.join(__dirname, '..', '..');
const projectRoot = '/workspace/app';
const plan = planInstallTargetScaffold({
target: 'cursor',
repoRoot,
projectRoot,
modules: [
{
id: 'rules-core',
paths: ['rules'],
},
{
id: 'platform-configs',
paths: ['.cursor'],
},
],
});
const commonAgentsDestinations = plan.operations.filter(operation => (
operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.mdc')
));
assert.strictEqual(commonAgentsDestinations.length, 1, 'Should keep only one common-agents.mdc operation');
assert.strictEqual(
normalizedRelativePath(commonAgentsDestinations[0].sourceRelativePath),
'rules/common/agents.md',
'Should prefer rules-core when cursor platform rules would collide'
);
})) passed++; else failed++;
if (test('prefers native cursor hooks when hooks-runtime and platform-configs overlap', () => {
const repoRoot = path.join(__dirname, '..', '..');
const projectRoot = '/workspace/app';
const plan = planInstallTargetScaffold({
target: 'cursor',
repoRoot,
projectRoot,
modules: [
{
id: 'hooks-runtime',
paths: ['hooks', 'scripts/hooks', 'scripts/lib'],
},
{
id: 'platform-configs',
paths: ['.cursor'],
},
],
});
const hooksDestinations = plan.operations.filter(operation => (
operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks')
));
assert.strictEqual(hooksDestinations.length, 1, 'Should keep only one .cursor/hooks scaffold operation');
assert.strictEqual(
normalizedRelativePath(hooksDestinations[0].sourceRelativePath),
'.cursor/hooks',
'Should prefer native Cursor hooks over generic hooks-runtime hooks'
);
})) passed++; else failed++;
if (test('plans antigravity remaps for workflows, skills, and flat rules', () => {

View File

@@ -34,6 +34,7 @@ const zhCnReadmePath = path.join(repoRoot, 'docs', 'zh-CN', 'README.md');
const selectiveInstallArchitecturePath = path.join(repoRoot, 'docs', 'SELECTIVE-INSTALL-ARCHITECTURE.md');
const opencodePackageJsonPath = path.join(repoRoot, '.opencode', 'package.json');
const opencodePackageLockPath = path.join(repoRoot, '.opencode', 'package-lock.json');
const opencodeHooksPluginPath = path.join(repoRoot, '.opencode', 'plugins', 'ecc-hooks.ts');
let passed = 0;
let failed = 0;
@@ -134,6 +135,13 @@ test('docs/SELECTIVE-INSTALL-ARCHITECTURE.md repoVersion example matches package
assert.strictEqual(match[1], expectedVersion);
});
test('.opencode/plugins/ecc-hooks.ts active plugin banner matches package.json', () => {
const source = fs.readFileSync(opencodeHooksPluginPath, 'utf8');
const match = source.match(/## Active Plugin: Everything Claude Code v([0-9]+\.[0-9]+\.[0-9]+)/);
assert.ok(match, 'Expected .opencode/plugins/ecc-hooks.ts to declare an active plugin banner');
assert.strictEqual(match[1], expectedVersion);
});
test('docs/pt-BR/README.md latest release heading matches package.json', () => {
const source = fs.readFileSync(ptBrReadmePath, 'utf8');
assert.ok(

View File

@@ -35,7 +35,7 @@ function main() {
["package.json exposes the OpenCode build and prepack hooks", () => {
assert.strictEqual(packageJson.scripts["build:opencode"], "node scripts/build-opencode.js")
assert.strictEqual(packageJson.scripts.prepack, "npm run build:opencode")
assert.ok(packageJson.files.includes(".opencode/dist/"))
assert.ok(packageJson.files.includes(".opencode/"))
}],
["build script generates .opencode/dist", () => {
const result = spawnSync("node", [buildScript], {

View File

@@ -130,8 +130,11 @@ function runTests() {
const result = run(['--target', 'cursor', 'typescript'], { cwd: projectDir, homeDir });
assert.strictEqual(result.code, 0, result.stderr);
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-coding-style.md')));
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'typescript-testing.md')));
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-coding-style.mdc')));
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'typescript-testing.mdc')));
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.mdc')));
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.md')));
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'README.mdc')));
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'agents', 'architect.md')));
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'commands', 'plan.md')));
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
@@ -304,7 +307,8 @@ function runTests() {
});
assert.strictEqual(result.code, 0, result.stderr);
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.md')));
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.mdc')));
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.md')));
const state = readJson(path.join(projectDir, '.cursor', 'ecc-install-state.json'));
assert.strictEqual(state.request.profile, null);

View File

@@ -110,6 +110,17 @@ function runTests() {
}
})) passed++; else failed++;
if (!powerShellCommand) {
console.log(' - skipped help text test; PowerShell is not available in PATH');
} else if (test('exposes the corrected Claude target help text', () => {
const result = run(powerShellCommand, ['--help']);
assert.strictEqual(result.code, 0, result.stderr);
assert.ok(
result.stdout.includes('claude (default) - Install ECC into ~/.claude/'),
'help text should describe the Claude target as a full ~/.claude install surface'
);
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}

View File

@@ -86,6 +86,15 @@ function runTests() {
}
})) passed++; else failed++;
if (test('exposes the corrected Claude target help text', () => {
const result = run(['--help']);
assert.strictEqual(result.code, 0, result.stderr);
assert.ok(
result.stdout.includes('claude (default) - Install ECC into ~/.claude/'),
'help text should describe the Claude target as a full ~/.claude install surface'
);
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}

View File

@@ -0,0 +1,71 @@
/**
* Regression coverage for supported manual Claude hook installation guidance.
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const README = path.join(__dirname, '..', '..', 'README.md');
const HOOKS_README = path.join(__dirname, '..', '..', 'hooks', 'README.md');
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
return true;
} catch (error) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function runTests() {
console.log('\n=== Testing manual hook install docs ===\n');
let passed = 0;
let failed = 0;
const readme = fs.readFileSync(README, 'utf8');
const hooksReadme = fs.readFileSync(HOOKS_README, 'utf8');
if (test('README warns against raw hook file copying', () => {
assert.ok(
readme.includes('Do not copy the raw repo `hooks/hooks.json` into `~/.claude/settings.json` or `~/.claude/hooks/hooks.json`'),
'README should warn against unsupported raw hook copying'
);
assert.ok(
readme.includes('bash ./install.sh --target claude --modules hooks-runtime'),
'README should document the supported Bash hook install path'
);
assert.ok(
readme.includes('pwsh -File .\\install.ps1 --target claude --modules hooks-runtime'),
'README should document the supported PowerShell hook install path'
);
assert.ok(
readme.includes('%USERPROFILE%\\\\.claude'),
'README should call out the correct Windows Claude config root'
);
})) passed++; else failed++;
if (test('hooks/README mirrors supported manual install guidance', () => {
assert.ok(
hooksReadme.includes('do not paste the raw repo `hooks.json` into `~/.claude/settings.json` or copy it directly into `~/.claude/hooks/hooks.json`'),
'hooks/README should warn against unsupported raw hook copying'
);
assert.ok(
hooksReadme.includes('bash ./install.sh --target claude --modules hooks-runtime'),
'hooks/README should document the supported Bash hook install path'
);
assert.ok(
hooksReadme.includes('pwsh -File .\\install.ps1 --target claude --modules hooks-runtime'),
'hooks/README should document the supported PowerShell hook install path'
);
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();

View File

@@ -0,0 +1,150 @@
/**
* Tests for the npm publish surface contract.
*/
const assert = require("assert")
const fs = require("fs")
const path = require("path")
const { spawnSync } = require("child_process")
function runTest(name, fn) {
try {
fn()
console.log(`${name}`)
return true
} catch (error) {
console.log(`${name}`)
console.error(` ${error.message}`)
return false
}
}
function normalizePublishPath(value) {
return String(value).replace(/\\/g, "/").replace(/\/$/, "")
}
function isCoveredByAncestor(target, roots) {
const parts = target.split("/")
for (let index = 1; index < parts.length; index += 1) {
const ancestor = parts.slice(0, index).join("/")
if (roots.has(ancestor)) {
return true
}
}
return false
}
function buildExpectedPublishPaths(repoRoot) {
const modules = JSON.parse(
fs.readFileSync(path.join(repoRoot, "manifests", "install-modules.json"), "utf8")
).modules
const extraPaths = [
"manifests",
"scripts/ecc.js",
"scripts/catalog.js",
"scripts/claw.js",
"scripts/doctor.js",
"scripts/status.js",
"scripts/sessions-cli.js",
"scripts/install-apply.js",
"scripts/install-plan.js",
"scripts/list-installed.js",
"scripts/skill-create-output.js",
"scripts/repair.js",
"scripts/harness-audit.js",
"scripts/session-inspect.js",
"scripts/uninstall.js",
"scripts/gemini-adapt-agents.js",
"scripts/codex/merge-codex-config.js",
"scripts/codex/merge-mcp-config.js",
".codex-plugin",
".mcp.json",
"install.sh",
"install.ps1",
"schemas",
"agent.yaml",
"VERSION",
]
const combined = new Set(
[...modules.flatMap((module) => module.paths || []), ...extraPaths].map(normalizePublishPath)
)
return [...combined]
.filter((publishPath) => !isCoveredByAncestor(publishPath, combined))
.sort()
}
function main() {
console.log("\n=== Testing npm publish surface ===\n")
let passed = 0
let failed = 0
const repoRoot = path.join(__dirname, "..", "..")
const packageJson = JSON.parse(
fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")
)
const expectedPublishPaths = buildExpectedPublishPaths(repoRoot)
const actualPublishPaths = packageJson.files.map(normalizePublishPath).sort()
const tests = [
["package.json files align to the module graph and explicit runtime allowlist", () => {
assert.deepStrictEqual(actualPublishPaths, expectedPublishPaths)
}],
["npm pack publishes the reduced runtime surface", () => {
const result = spawnSync("npm", ["pack", "--dry-run", "--json"], {
cwd: repoRoot,
encoding: "utf8",
shell: process.platform === "win32",
})
assert.strictEqual(result.status, 0, result.error?.message || result.stderr)
const packOutput = JSON.parse(result.stdout)
const packagedPaths = new Set(packOutput[0]?.files?.map((file) => file.path) ?? [])
for (const requiredPath of [
"scripts/catalog.js",
".gemini/GEMINI.md",
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
"schemas/install-state.schema.json",
"skills/backend-patterns/SKILL.md",
]) {
assert.ok(
packagedPaths.has(requiredPath),
`npm pack should include ${requiredPath}`
)
}
for (const excludedPath of [
"contexts/dev.md",
"examples/CLAUDE.md",
"plugins/README.md",
"scripts/ci/catalog.js",
"skills/skill-comply/SKILL.md",
]) {
assert.ok(
!packagedPaths.has(excludedPath),
`npm pack should not include ${excludedPath}`
)
}
}],
]
for (const [name, fn] of tests) {
if (runTest(name, fn)) {
passed += 1
} else {
failed += 1
}
}
console.log(`\nPassed: ${passed}`)
console.log(`Failed: ${failed}`)
process.exit(failed > 0 ? 1 : 0)
}
main()

69
tests/test_builder.py Normal file
View File

@@ -0,0 +1,69 @@
import pytest
from llm.core.types import LLMInput, Message, Role, ToolDefinition
from llm.prompt import PromptBuilder, adapt_messages_for_provider
from llm.prompt.builder import PromptConfig
class TestPromptBuilder:
def test_build_without_system(self):
messages = [Message(role=Role.USER, content="Hello")]
builder = PromptBuilder()
result = builder.build(messages)
assert len(result) == 1
assert result[0].role == Role.USER
def test_build_with_system(self):
messages = [
Message(role=Role.SYSTEM, content="You are helpful."),
Message(role=Role.USER, content="Hello"),
]
builder = PromptBuilder()
result = builder.build(messages)
assert len(result) == 2
assert result[0].role == Role.SYSTEM
def test_build_adds_system_from_config(self):
messages = [Message(role=Role.USER, content="Hello")]
builder = PromptBuilder(system_template="You are a pirate.")
result = builder.build(messages)
assert len(result) == 2
assert "pirate" in result[0].content
def test_build_adds_system_from_config(self):
messages = [Message(role=Role.USER, content="Hello")]
builder = PromptBuilder(config=PromptConfig(system_template="You are a pirate."))
result = builder.build(messages)
assert len(result) == 2
assert "pirate" in result[0].content
def test_build_with_tools(self):
messages = [Message(role=Role.USER, content="Search for something")]
tools = [
ToolDefinition(name="search", description="Search the web", parameters={}),
]
builder = PromptBuilder(include_tools_in_system=True)
result = builder.build(messages, tools)
assert len(result) == 2
assert "search" in result[0].content
assert "Available Tools" in result[0].content
class TestAdaptMessagesForProvider:
def test_adapt_for_claude(self):
messages = [Message(role=Role.USER, content="Hello")]
result = adapt_messages_for_provider(messages, "claude")
assert len(result) == 1
def test_adapt_for_openai(self):
messages = [Message(role=Role.USER, content="Hello")]
result = adapt_messages_for_provider(messages, "openai")
assert len(result) == 1
def test_adapt_for_ollama(self):
messages = [Message(role=Role.USER, content="Hello")]
result = adapt_messages_for_provider(messages, "ollama")
assert len(result) == 1

86
tests/test_executor.py Normal file
View File

@@ -0,0 +1,86 @@
import pytest
from llm.core.types import ToolCall, ToolDefinition, ToolResult
from llm.tools import ToolExecutor, ToolRegistry
class TestToolRegistry:
def test_register_and_get(self):
registry = ToolRegistry()
def dummy_func() -> str:
return "result"
tool_def = ToolDefinition(
name="dummy",
description="A dummy tool",
parameters={"type": "object"},
)
registry.register(tool_def, dummy_func)
assert registry.has("dummy") is True
assert registry.get("dummy") is dummy_func
assert registry.get_definition("dummy") == tool_def
def test_list_tools(self):
registry = ToolRegistry()
tool_def = ToolDefinition(name="test", description="Test", parameters={})
registry.register(tool_def, lambda: None)
tools = registry.list_tools()
assert len(tools) == 1
assert tools[0].name == "test"
class TestToolExecutor:
def test_execute_success(self):
registry = ToolRegistry()
def search(query: str) -> str:
return f"Results for: {query}"
registry.register(
ToolDefinition(
name="search",
description="Search",
parameters={"type": "object", "properties": {"query": {"type": "string"}}},
),
search,
)
executor = ToolExecutor(registry)
result = executor.execute(ToolCall(id="1", name="search", arguments={"query": "test"}))
assert result.tool_call_id == "1"
assert result.content == "Results for: test"
assert result.is_error is False
def test_execute_unknown_tool(self):
registry = ToolRegistry()
executor = ToolExecutor(registry)
result = executor.execute(ToolCall(id="1", name="unknown", arguments={}))
assert result.is_error is True
assert "not found" in result.content
def test_execute_all(self):
registry = ToolRegistry()
def tool1() -> str:
return "result1"
def tool2() -> str:
return "result2"
registry.register(ToolDefinition(name="t1", description="", parameters={}), tool1)
registry.register(ToolDefinition(name="t2", description="", parameters={}), tool2)
executor = ToolExecutor(registry)
results = executor.execute_all([
ToolCall(id="1", name="t1", arguments={}),
ToolCall(id="2", name="t2", arguments={}),
])
assert len(results) == 2
assert results[0].content == "result1"
assert results[1].content == "result2"

28
tests/test_resolver.py Normal file
View File

@@ -0,0 +1,28 @@
import pytest
from llm.core.types import ProviderType
from llm.providers import ClaudeProvider, OpenAIProvider, OllamaProvider, get_provider
class TestGetProvider:
def test_get_claude_provider(self):
provider = get_provider("claude")
assert isinstance(provider, ClaudeProvider)
assert provider.provider_type == ProviderType.CLAUDE
def test_get_openai_provider(self):
provider = get_provider("openai")
assert isinstance(provider, OpenAIProvider)
assert provider.provider_type == ProviderType.OPENAI
def test_get_ollama_provider(self):
provider = get_provider("ollama")
assert isinstance(provider, OllamaProvider)
assert provider.provider_type == ProviderType.OLLAMA
def test_get_provider_by_enum(self):
provider = get_provider(ProviderType.CLAUDE)
assert isinstance(provider, ClaudeProvider)
def test_invalid_provider_raises(self):
with pytest.raises(ValueError, match="Unknown provider type"):
get_provider("invalid")

117
tests/test_types.py Normal file
View File

@@ -0,0 +1,117 @@
import pytest
from llm.core.types import (
LLMInput,
LLMOutput,
Message,
ModelInfo,
ProviderType,
Role,
ToolCall,
ToolDefinition,
ToolResult,
)
class TestRole:
def test_role_values(self):
assert Role.SYSTEM.value == "system"
assert Role.USER.value == "user"
assert Role.ASSISTANT.value == "assistant"
assert Role.TOOL.value == "tool"
class TestProviderType:
def test_provider_values(self):
assert ProviderType.CLAUDE.value == "claude"
assert ProviderType.OPENAI.value == "openai"
assert ProviderType.OLLAMA.value == "ollama"
class TestMessage:
def test_create_message(self):
msg = Message(role=Role.USER, content="Hello")
assert msg.role == Role.USER
assert msg.content == "Hello"
assert msg.name is None
assert msg.tool_call_id is None
def test_message_to_dict(self):
msg = Message(role=Role.USER, content="Hello", name="test")
result = msg.to_dict()
assert result["role"] == "user"
assert result["content"] == "Hello"
assert result["name"] == "test"
class TestToolDefinition:
def test_create_tool(self):
tool = ToolDefinition(
name="search",
description="Search the web",
parameters={"type": "object", "properties": {}},
)
assert tool.name == "search"
assert tool.strict is True
def test_tool_to_dict(self):
tool = ToolDefinition(
name="search",
description="Search",
parameters={"type": "object"},
)
result = tool.to_dict()
assert result["name"] == "search"
assert result["strict"] is True
class TestToolCall:
def test_create_tool_call(self):
tc = ToolCall(id="1", name="search", arguments={"query": "test"})
assert tc.id == "1"
assert tc.name == "search"
assert tc.arguments == {"query": "test"}
class TestToolResult:
def test_create_tool_result(self):
result = ToolResult(tool_call_id="1", content="result")
assert result.tool_call_id == "1"
assert result.is_error is False
class TestLLMInput:
def test_create_input(self):
messages = [Message(role=Role.USER, content="Hello")]
input_obj = LLMInput(messages=messages, temperature=0.7)
assert len(input_obj.messages) == 1
assert input_obj.temperature == 0.7
def test_input_to_dict(self):
messages = [Message(role=Role.USER, content="Hello")]
input_obj = LLMInput(messages=messages)
result = input_obj.to_dict()
assert "messages" in result
assert result["temperature"] == 1.0
class TestLLMOutput:
def test_create_output(self):
output = LLMOutput(content="Hello!")
assert output.content == "Hello!"
assert output.has_tool_calls is False
def test_output_with_tool_calls(self):
tc = ToolCall(id="1", name="search", arguments={})
output = LLMOutput(content="", tool_calls=[tc])
assert output.has_tool_calls is True
class TestModelInfo:
def test_create_model_info(self):
info = ModelInfo(
name="gpt-4",
provider=ProviderType.OPENAI,
)
assert info.name == "gpt-4"
assert info.supports_tools is True
assert info.supports_vision is False