mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-29 21:15:16 +02:00
feat(profiles): add opt-in Lean/Full and hybrid Auto selection
This commit is contained in:
@@ -404,6 +404,7 @@ function runHermeticPythonPrePush({
|
||||
? process.env.PATH
|
||||
: `${toBashPath(pathBin)}${path.delimiter}${process.env.PATH}`,
|
||||
HOME: process.env.HOME ?? '',
|
||||
ECC_PREPUSH_RUN_CHECKS: '1',
|
||||
ECC_SKIP_GIT_HOOKS: '0',
|
||||
ECC_SKIP_PREPUSH: '0',
|
||||
MSYS_NO_PATHCONV: '1',
|
||||
|
||||
@@ -604,9 +604,10 @@ async function runTests() {
|
||||
if (
|
||||
await test('CLI browser opener handles spawn errors', async () => {
|
||||
const source = fs.readFileSync(SCRIPT, 'utf8');
|
||||
|
||||
assert.match(source, /child\.on\('error'/);
|
||||
assert.match(source, /child\.unref\(\)/);
|
||||
const helper = fs.readFileSync(path.join(path.dirname(SCRIPT), 'lib/platform-launch.js'), 'utf8');
|
||||
assert.match(source, /require\('\.\/lib\/platform-launch'\)/);
|
||||
assert.match(helper, /child\.on\('error'/);
|
||||
assert.match(helper, /child\.unref\(\)/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
|
||||
@@ -1104,7 +1104,7 @@ function runTests() {
|
||||
assert.strictEqual(fs.readFileSync(scriptsPackagePath, 'utf8'), userScriptsPackage);
|
||||
|
||||
const state = readJson(path.join(claudeRoot, 'ecc', 'install-state.json'));
|
||||
const boundaryPaths = [hooksPackagePath, libPackagePath];
|
||||
const boundaryPaths = [hooksPackagePath, libPackagePath].map(file => fs.realpathSync(file));
|
||||
const packageBoundaryOperations = state.operations.filter(operation => (
|
||||
boundaryPaths.includes(operation.destinationPath)
|
||||
));
|
||||
@@ -1191,6 +1191,7 @@ function runTests() {
|
||||
|
||||
applyInstallPlan({
|
||||
targetRoot: path.join(tempDir, 'installed'),
|
||||
adapter: { id: 'test-install', target: 'test-install' },
|
||||
installStatePath,
|
||||
statePreview: {
|
||||
schemaVersion: 'ecc.install.v1',
|
||||
|
||||
@@ -51,6 +51,7 @@ function buildExpectedPublishPaths(repoRoot) {
|
||||
"scripts/ci/scan-supply-chain-iocs.js",
|
||||
"scripts/ci/supply-chain-advisory-sources.js",
|
||||
"scripts/consult.js",
|
||||
"scripts/profile.js",
|
||||
"scripts/control-pane.js",
|
||||
"scripts/dashboard-web.js",
|
||||
"scripts/discussion-audit.js",
|
||||
@@ -108,6 +109,9 @@ function buildExpectedPublishPaths(repoRoot) {
|
||||
"docs/COMMAND-AGENT-MAP.md",
|
||||
"docs/ROADMAP.md",
|
||||
"docs/design/ecc-memory-vault.md",
|
||||
"docs/design/context-profiles.md",
|
||||
"docs/design/context-carriers.md",
|
||||
"docs/design/context-profile-delivery.md",
|
||||
"assets/images/sponsors",
|
||||
]
|
||||
const exclusionPaths = [
|
||||
@@ -185,6 +189,29 @@ function main() {
|
||||
"scripts/ci/scan-supply-chain-iocs.js",
|
||||
"scripts/ci/supply-chain-advisory-sources.js",
|
||||
"scripts/consult.js",
|
||||
"scripts/profile.js",
|
||||
"scripts/lib/context-profiles.js",
|
||||
"scripts/lib/context-pack-registry.js",
|
||||
"scripts/lib/context-profile-support.js",
|
||||
"scripts/lib/context-carriers.js",
|
||||
"scripts/lib/context-selection.js",
|
||||
"scripts/lib/context-profile-commands.js",
|
||||
"scripts/lib/context-profile-launch.js",
|
||||
"scripts/lib/context-profile-proposal.js",
|
||||
"scripts/lib/context-profile-native.js",
|
||||
"scripts/lib/context-profile-native-discovery.js",
|
||||
"scripts/lib/context-profile-native-executable.js",
|
||||
"scripts/lib/context-profile-store.js",
|
||||
"scripts/lib/context-profile-store-fs.js",
|
||||
"schemas/context-profile.schema.json",
|
||||
"schemas/context-pack-registry.schema.json",
|
||||
"schemas/context-carrier.schema.json",
|
||||
"manifests/context-profiles/lean@1.json",
|
||||
"manifests/context-profiles/full@1.json",
|
||||
"manifests/context-packs/skill-registry@1.json",
|
||||
"docs/design/context-profiles.md",
|
||||
"docs/design/context-carriers.md",
|
||||
"docs/design/context-profile-delivery.md",
|
||||
"scripts/control-pane.js",
|
||||
"scripts/feedback.js",
|
||||
"scripts/ito.js",
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const test = require('node:test');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
|
||||
function withReadOnlyCli(fn) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-carrier-cli-'));
|
||||
const user = path.join(root, 'user');
|
||||
const workspace = path.join(root, 'workspace');
|
||||
fs.mkdirSync(user);
|
||||
fs.mkdirSync(workspace);
|
||||
fs.writeFileSync(path.join(user, 'settings.json'), '{"existing":true}\n');
|
||||
try {
|
||||
fn(args => spawnSync(process.execPath, [path.join(ROOT, 'scripts/ecc.js'), 'profile', ...args], {
|
||||
cwd: workspace, encoding: 'utf8', timeout: 30000, maxBuffer: 4 * 1024 * 1024,
|
||||
env: {
|
||||
PATH: process.env.PATH, SystemRoot: process.env.SystemRoot,
|
||||
HOME: user, USERPROFILE: user, CODEX_HOME: path.join(user, '.codex'),
|
||||
CLAUDE_CONFIG_DIR: path.join(user, '.claude'),
|
||||
XDG_CONFIG_HOME: path.join(user, 'config'), XDG_STATE_HOME: path.join(user, 'state'),
|
||||
...(process.env.NODE_V8_COVERAGE ? { NODE_V8_COVERAGE: process.env.NODE_V8_COVERAGE } : {}),
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
try {
|
||||
assert.deepEqual(fs.readdirSync(root).sort(), ['user', 'workspace']);
|
||||
assert.deepEqual(fs.readdirSync(user), ['settings.json']);
|
||||
assert.equal(fs.readFileSync(path.join(user, 'settings.json'), 'utf8'), '{"existing":true}\n');
|
||||
assert.deepEqual(fs.readdirSync(workspace), []);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
}
|
||||
}
|
||||
|
||||
function payload(result) {
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const value = JSON.parse(result.stdout);
|
||||
assert.equal(value.status, 'warning');
|
||||
assert.equal(value.activation, 'unobserved');
|
||||
assert.equal(value.carrier.active, false);
|
||||
assert.equal(value.carrier.nativeSupport, 'unobserved');
|
||||
return value;
|
||||
}
|
||||
|
||||
test('profile help exposes carrier planning without a write command', () => withReadOnlyCli(run => {
|
||||
const result = run(['--help']);
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout, /ecc profile carrier/);
|
||||
assert.match(result.stdout, /read-only/i);
|
||||
}));
|
||||
|
||||
test('default carrier preview is a deterministic Lean Codex proposal', () => withReadOnlyCli(run => {
|
||||
const first = payload(run(['carrier', '--json']));
|
||||
assert.deepEqual(payload(run(['carrier', '--json'])), first);
|
||||
assert.equal(first.carrier.target, 'codex');
|
||||
assert.equal(first.carrier.profileId, 'lean@1');
|
||||
assert.equal(first.carrier.selectionMode, 'auto');
|
||||
assert.equal(first.carrier.selectedIds.length, 3);
|
||||
assert.equal(first.carrier.status, 'planned');
|
||||
assert.equal(first.artifacts[0].digest, first.carrier.carrierDigest);
|
||||
assert.ok(!JSON.stringify(first).includes(ROOT));
|
||||
}));
|
||||
|
||||
test('Full carrier keeps explicit exclusions and manual intent', () => withReadOnlyCli(run => {
|
||||
const { carrier } = payload(run(['carrier', 'full@1', '--selection', 'manual',
|
||||
'--exclude', 'skill:python-patterns', '--json']));
|
||||
assert.equal(carrier.selectionMode, 'manual');
|
||||
assert.ok(carrier.excludedIds.includes('skill:python-patterns'));
|
||||
assert.ok(carrier.files.every(file => file.skillId !== 'skill:python-patterns'));
|
||||
}));
|
||||
|
||||
test('every implemented layout remains explicitly native-unobserved', () => withReadOnlyCli(run => {
|
||||
for (const target of ['claude', 'codex', 'cursor', 'opencode', 'pi']) {
|
||||
const { carrier } = payload(run(['carrier', '--target', target, '--json']));
|
||||
assert.equal(carrier.target, target);
|
||||
assert.equal(carrier.status, 'planned');
|
||||
assert.ok(carrier.files.length > 0);
|
||||
}
|
||||
}));
|
||||
|
||||
test('recognized unsupported target returns inventory without generated files', () => withReadOnlyCli(run => {
|
||||
const { carrier } = payload(run(['carrier', '--target', 'kimi', '--json']));
|
||||
assert.equal(carrier.status, 'unsupported');
|
||||
assert.deepEqual(carrier.files, []);
|
||||
assert.equal(carrier.selectedIds.length, 3);
|
||||
}));
|
||||
|
||||
test('carrier text and dry-run output preserve read-only and unobserved boundaries', () => withReadOnlyCli(run => {
|
||||
const result = run(['carrier', '--dry-run']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /carrier/i);
|
||||
assert.match(result.stdout, /unobserved/i);
|
||||
assert.match(result.stdout, /planned|proposed/i);
|
||||
const expected = payload(run(['carrier', 'lean@1', '--target', 'codex', '--json']));
|
||||
for (const args of [
|
||||
['--dry-run', 'carrier', 'lean@1', '--target', 'codex', '--json'],
|
||||
['carrier', 'lean@1', '--target', '--dry-run', 'codex', '--json'],
|
||||
]) assert.deepEqual(payload(run(args)), expected);
|
||||
}));
|
||||
|
||||
test('carrier rejects unknown targets, write destinations, and hook flags', () => withReadOnlyCli(run => {
|
||||
for (const args of [['--target', 'unknown'], ['--output', 'user'], ['--hooks', 'strict']]) {
|
||||
const result = run(['carrier', ...args, '--json']);
|
||||
assert.equal(result.status, 1);
|
||||
const error = JSON.parse(result.stdout);
|
||||
assert.equal(error.status, 'error');
|
||||
assert.doesNotMatch(error.summary, /Cannot find module|Require stack/);
|
||||
}
|
||||
}));
|
||||
@@ -0,0 +1,60 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const test = require('node:test');
|
||||
const CLI = path.resolve(__dirname, '../../scripts/ecc.js');
|
||||
const task = { sessionId: 'stdin-session', taskId: 'stdin-task', revision: 1,
|
||||
phase: 'implement', explicitIds: ['skill:python-patterns'] };
|
||||
function invoke(args, input) {
|
||||
return spawnSync(process.execPath, [CLI, 'profile', ...args, '--json'], {
|
||||
input, encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024 });
|
||||
}
|
||||
|
||||
test('resolve accepts bounded task JSON on stdin without creating task files', () => {
|
||||
const result = invoke(['resolve', '--task-input', '-', '--load'], JSON.stringify(task));
|
||||
assert.equal(result.status, 0, result.stdout);
|
||||
assert.deepEqual(JSON.parse(result.stdout).selection.loadedIds, ['skill:python-patterns']);
|
||||
});
|
||||
|
||||
test('stdin task JSON rejects overflow, malformed UTF-8, NUL, and invalid JSON', () => {
|
||||
for (const [input, message] of [[Buffer.alloc(65537, 32), /65536/], [Buffer.from([0xff]), /UTF-8/],
|
||||
['\0', /UTF-8/], ['{', /JSON/]]) {
|
||||
const result = invoke(['resolve', '--task-input', '-'], input);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(JSON.parse(result.stdout).summary, message);
|
||||
}
|
||||
});
|
||||
|
||||
test('malformed task JSON never echoes private input through the CLI envelope', () => {
|
||||
const secret = 'PRIVATE_TASK_SENTINEL';
|
||||
const result = invoke(['resolve', '--task-input', '-'], `{"task":"${secret}"`);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(JSON.parse(result.stdout).summary, /valid JSON/);
|
||||
assert.doesNotMatch(result.stdout + result.stderr, new RegExp(secret));
|
||||
});
|
||||
|
||||
test('start requires both roots, rejects authority flags, and dry-run never prepares a native home', () => {
|
||||
const parent = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'ecc-start-cli-'));
|
||||
try {
|
||||
const stateRoot = path.join(parent, 'state');
|
||||
const nativeRoot = path.join(parent, 'native');
|
||||
for (const [args, message] of [[[], /requires --state-root/],
|
||||
[['--state-root', stateRoot], /requires --native-root/],
|
||||
[['--state-root', stateRoot, '--native-root', nativeRoot, '--dangerously-bypass-approvals-and-sandbox'], /Unknown argument/]]) {
|
||||
const result = invoke(['start', ...args]);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(JSON.parse(result.stdout).summary, message);
|
||||
}
|
||||
assert.equal(invoke(['set', 'lean', '--state-root', stateRoot]).status, 0);
|
||||
const jsonStart = invoke(['start', '--state-root', stateRoot, '--native-root', nativeRoot]);
|
||||
assert.equal(jsonStart.status, 1);
|
||||
assert.match(JSON.parse(jsonStart.stdout).summary, /--json requires --dry-run/);
|
||||
const result = invoke(['start', '--state-root', stateRoot, '--native-root', nativeRoot, '--dry-run']);
|
||||
assert.equal(result.status, 0, result.stdout);
|
||||
assert.equal(JSON.parse(result.stdout).interactive.status, 'proposed');
|
||||
assert.equal(fs.existsSync(nativeRoot), false);
|
||||
} finally { fs.rmSync(parent, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const test = require('node:test');
|
||||
const { withFixture } = require('../lib/helpers/context-fixture');
|
||||
const CLI = path.resolve(__dirname, '../../scripts/ecc.js');
|
||||
const PROFILE_CLI = path.resolve(__dirname, '../../scripts/profile.js');
|
||||
|
||||
function cliFixture(run) {
|
||||
const root = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'ecc-selection-cli-'));
|
||||
const stateRoot = path.join(root, 'managed');
|
||||
const input = path.join(root, 'task.json');
|
||||
const setTask = values => fs.writeFileSync(input, JSON.stringify({ sessionId: 'test', taskId: 'test',
|
||||
revision: 1, phase: 'implement', query: 'Explain Python lists', ...values }));
|
||||
setTask({ proposedIds: ['skill:python-patterns'] });
|
||||
const invoke = (args, { preload, env = {} } = {}) => {
|
||||
const entry = preload ? ['--require', preload, PROFILE_CLI] : [CLI, 'profile'];
|
||||
const child = spawnSync(process.execPath, [...entry, ...args, '--json'], {
|
||||
cwd: root, encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024,
|
||||
env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot,
|
||||
NODE_V8_COVERAGE: process.env.NODE_V8_COVERAGE, ...env },
|
||||
});
|
||||
assert.ok(child.stdout, child.stderr || child.error?.message);
|
||||
return { code: child.status, response: JSON.parse(child.stdout) };
|
||||
};
|
||||
try { return run({ root, stateRoot, input, setTask, invoke }); }
|
||||
finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
function providerFixture(root) {
|
||||
const preload = path.join(root, 'provider-preload.cjs');
|
||||
const sentinel = path.join(root, 'provider-executed');
|
||||
fs.writeFileSync(preload, `
|
||||
const cp = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const original = cp.spawnSync;
|
||||
cp.spawnSync = (command, ...args) => {
|
||||
if (command !== 'codex' && command !== 'claude') return original(command, ...args);
|
||||
fs.writeFileSync(process.env.ECC_TEST_PROVIDER_SENTINEL, 'executed');
|
||||
return { status: Number(process.env.ECC_TEST_PROVIDER_STATUS || 0),
|
||||
stdout: 'fixture output', stderr: 'fixture provider failure' };
|
||||
};
|
||||
`);
|
||||
return { preload, sentinel, env: { ECC_TEST_PROVIDER_SENTINEL: sentinel } };
|
||||
}
|
||||
|
||||
test('CLI resolves and loads explicit task context with JSON output', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-selection-cli-'));
|
||||
try {
|
||||
const input = path.join(root, 'task.json');
|
||||
fs.writeFileSync(input, JSON.stringify({ sessionId: 'test', taskId: 'test', revision: 1, phase: 'implement',
|
||||
explicitIds: ['skill:python-patterns'] }));
|
||||
const args = ['profile', 'resolve', '--task-input', input, '--load', '--json'];
|
||||
const run = extra => spawnSync(process.execPath, [CLI, ...extra, ...args], {
|
||||
cwd: root, encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024,
|
||||
env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot,
|
||||
NODE_V8_COVERAGE: process.env.NODE_V8_COVERAGE },
|
||||
});
|
||||
const result = run([]);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.deepEqual(JSON.parse(result.stdout).selection.loadedIds, ['skill:python-patterns']);
|
||||
const preview = run(['--dry-run']);
|
||||
assert.equal(preview.status, 0, preview.stderr || preview.stdout);
|
||||
assert.deepEqual(JSON.parse(preview.stdout).selection.loadedIds, []);
|
||||
assert.deepEqual(fs.readdirSync(root), ['task.json']);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('resolver rejects unknown flags before reading task input', () => {
|
||||
const result = spawnSync(process.execPath, [CLI, 'profile', 'resolve', '--task-input', 'missing', '--hooks', 'full', '--json'], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(JSON.parse(result.stdout).summary, /Unknown argument/);
|
||||
});
|
||||
|
||||
test('saved mode and exclusions govern resolution and survive mode changes', () => cliFixture(({ stateRoot, input, setTask, invoke }) => {
|
||||
const setup = invoke(['set', 'lean', '--state-root', stateRoot, '--selection', 'suggest',
|
||||
'--include', 'skill:python-patterns', '--exclude', 'skill:python-testing']);
|
||||
assert.equal(setup.code, 0, setup.response.summary);
|
||||
const resolveArgs = ['resolve', '--state-root', stateRoot, '--task-input', input, '--load'];
|
||||
const suggestion = invoke(resolveArgs);
|
||||
assert.equal(suggestion.code, 0, suggestion.response.summary);
|
||||
assert.equal(suggestion.response.selection.selectionMode, 'suggest');
|
||||
assert.deepEqual(suggestion.response.selection.selectedIds, ['skill:python-patterns']);
|
||||
assert.deepEqual(suggestion.response.selection.loadedIds, []);
|
||||
|
||||
const preview = invoke(['mode', 'manual', '--state-root', stateRoot, '--dry-run']);
|
||||
assert.equal(preview.code, 0, preview.response.summary);
|
||||
assert.equal(preview.response.store.status, 'proposed');
|
||||
assert.equal(invoke(['status', '--state-root', stateRoot]).response.store.selectionMode, 'suggest');
|
||||
const changed = invoke(['mode', 'manual', '--state-root', stateRoot, '--expected-revision', '1']);
|
||||
assert.equal(changed.code, 0, changed.response.summary);
|
||||
assert.deepEqual(changed.response.store.include, ['skill:python-patterns']);
|
||||
assert.deepEqual(changed.response.store.exclude, ['skill:python-testing']);
|
||||
const manual = invoke(resolveArgs);
|
||||
assert.equal(manual.code, 0, manual.response.summary);
|
||||
assert.deepEqual(manual.response.selection.selectedIds, []);
|
||||
assert.equal(manual.response.selection.selectionMode, 'manual');
|
||||
|
||||
setTask({ explicitIds: ['skill:python-testing'] });
|
||||
const excluded = invoke(resolveArgs);
|
||||
assert.equal(excluded.code, 1);
|
||||
assert.match(excluded.response.summary, /excluded/);
|
||||
setTask({ proposedIds: ['skill:python-patterns'] });
|
||||
assert.equal(invoke(['mode', 'auto', '--state-root', stateRoot]).code, 0);
|
||||
const automatic = invoke(resolveArgs);
|
||||
assert.equal(automatic.code, 0, automatic.response.summary);
|
||||
assert.deepEqual(automatic.response.selection.loadedIds, ['skill:python-patterns']);
|
||||
}));
|
||||
|
||||
test('stored resolution and launch reject every configuration override before reading input', () => cliFixture(({ stateRoot, invoke }) => {
|
||||
for (const command of ['resolve', 'run']) {
|
||||
for (const override of [['full'], ['--target', 'claude'], ['--selection', 'manual'],
|
||||
['--include', 'skill:python-patterns'], ['--exclude', 'skill:python-testing']]) {
|
||||
const result = invoke([command, '--state-root', stateRoot, '--task-input', 'missing', ...override]);
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.response.summary, /cannot override/);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
test('launch dry runs never load bodies or execute a provider', () => cliFixture(({ root, input, invoke }) => {
|
||||
const provider = providerFixture(root);
|
||||
for (const dry of [{ args: ['--dry-run'], env: {} }, { args: [], env: { ECC_DRY_RUN: '1' } }]) {
|
||||
const result = invoke(['run', '--task-input', input, ...dry.args],
|
||||
{ preload: provider.preload, env: { ...provider.env, ...dry.env } });
|
||||
assert.equal(result.code, 0, result.response.summary);
|
||||
assert.equal(result.response.launch.status, 'proposed');
|
||||
assert.deepEqual(result.response.launch.selection.loadedIds, []);
|
||||
assert.equal(fs.existsSync(provider.sentinel), false);
|
||||
}
|
||||
}));
|
||||
|
||||
test('provider exit failures produce a failed CLI result and preserve the native exit code', () => cliFixture(({ root, input, invoke }) => {
|
||||
const provider = providerFixture(root);
|
||||
const result = invoke(['run', '--task-input', input], { preload: provider.preload,
|
||||
env: { ...provider.env, ECC_TEST_PROVIDER_STATUS: '23' } });
|
||||
assert.equal(fs.readFileSync(provider.sentinel, 'utf8'), 'executed');
|
||||
assert.equal(result.code, 1);
|
||||
assert.equal(result.response.status, 'error');
|
||||
assert.equal(result.response.launch.status, 'failed');
|
||||
assert.equal(result.response.launch.exitCode, 23);
|
||||
assert.equal(result.response.launch.taskSuccess, 'unverified');
|
||||
assert.match(result.response.launch.error, /fixture provider failure/);
|
||||
}));
|
||||
|
||||
test('unsupported targets and stale selection digests fail before provider execution', () => cliFixture(({ root, input, invoke }) => {
|
||||
const provider = providerFixture(root);
|
||||
for (const args of [['--target', 'pi'], ['--expected-digest', '0'.repeat(64)]]) {
|
||||
const result = invoke(['run', '--task-input', input, ...args], provider);
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.response.summary, /Unsupported|stale/);
|
||||
assert.equal(fs.existsSync(provider.sentinel), false);
|
||||
}
|
||||
}));
|
||||
|
||||
test('unconfigured and source-stale stores cannot resolve task context', () => cliFixture(({ stateRoot, input, invoke }) => {
|
||||
const args = ['resolve', '--state-root', stateRoot, '--task-input', input, '--load'];
|
||||
const absent = invoke(args);
|
||||
assert.equal(absent.code, 1);
|
||||
assert.match(absent.response.summary, /Configure or recover/);
|
||||
withFixture(repoRoot => require('../../scripts/lib/context-profile-store').applyStore({ repoRoot, stateRoot }));
|
||||
const stale = invoke(args);
|
||||
assert.equal(stale.code, 1);
|
||||
assert.match(stale.response.summary, /source is stale/);
|
||||
}));
|
||||
|
||||
test('malformed operation flags and stale write preconditions fail without creating a store', () => cliFixture(({ stateRoot, invoke }) => {
|
||||
for (const [args, message] of [
|
||||
[['mode', 'unknown', '--state-root', stateRoot], /Choose mode/],
|
||||
[['set', 'lean', '--state-root'], /Missing value/],
|
||||
[['set', 'lean', '--state-root', stateRoot, '--state-root', stateRoot], /Duplicate argument/],
|
||||
[['set', 'lean', '--state-root', stateRoot, '--task-input', 'missing'], /unavailable/],
|
||||
[['set', 'lean', '--state-root', stateRoot, '--expected-revision', '01'], /nonnegative integer/],
|
||||
[['set', 'lean', '--state-root', stateRoot, '--expected-revision', '1'], /revision changed/],
|
||||
[['set', 'lean', '--state-root', stateRoot, '--expected-digest', 'bad'], /Invalid expected/],
|
||||
[['set', 'lean', '--state-root', stateRoot, '--expected-digest', '0'.repeat(64)], /digest changed/],
|
||||
[['run', '--task-input', 'missing', '--load'], /Unknown argument/],
|
||||
]) {
|
||||
const result = invoke(args);
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.response.summary, message);
|
||||
assert.equal(fs.existsSync(stateRoot), false);
|
||||
}
|
||||
}));
|
||||
|
||||
test('native command routing rejects missing roots and unsupported flags before provider or filesystem work', () => cliFixture(({ root, stateRoot, invoke }) => {
|
||||
const nativeRoot = path.join(root, 'native');
|
||||
for (const command of ['prepare-native', 'native-status', 'native-rollback', 'native-recover']) {
|
||||
for (const [args, pattern] of [
|
||||
[[], /requires --state-root/],
|
||||
[['--state-root', stateRoot], /requires --native-root/],
|
||||
[['--state-root', stateRoot, '--native-root', nativeRoot, '--target', 'codex'], /unavailable/],
|
||||
[['--state-root', stateRoot, '--native-root', nativeRoot, '--expected-revision', '1e2'], /nonnegative integer/],
|
||||
]) {
|
||||
const result = invoke([command, ...args]);
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.response.summary, pattern);
|
||||
}
|
||||
}
|
||||
const orphan = invoke(['run', '--native-root', nativeRoot, '--task-input', 'missing']);
|
||||
assert.equal(orphan.code, 1);
|
||||
assert.match(orphan.response.summary, /--native-root requires --state-root/);
|
||||
const invalidResolve = invoke(['resolve', '--state-root', stateRoot, '--native-root', nativeRoot, '--task-input', 'missing']);
|
||||
assert.equal(invalidResolve.code, 1);
|
||||
assert.match(invalidResolve.response.summary, /--native-root is unavailable/);
|
||||
assert.equal(fs.existsSync(stateRoot), false);
|
||||
assert.equal(fs.existsSync(nativeRoot), false);
|
||||
}));
|
||||
@@ -0,0 +1,206 @@
|
||||
/** Read-only context profile journeys, exercised through the shipped CLI. */
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
const CLI = path.join(ROOT, 'scripts/ecc.js');
|
||||
const PROFILE = path.join(ROOT, 'scripts/profile.js');
|
||||
|
||||
function snapshot(directory) {
|
||||
return fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))
|
||||
.flatMap(entry => {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isSymbolicLink()) return [`${entry.name}:link:${fs.readlinkSync(file)}`];
|
||||
return entry.isDirectory()
|
||||
? snapshot(file).map(item => `${entry.name}/${item}`)
|
||||
: [`${entry.name}:${crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')}`];
|
||||
});
|
||||
}
|
||||
|
||||
function withFixture(fn) {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-profile-cli-'));
|
||||
let before;
|
||||
try {
|
||||
const userDirectory = path.join(fixture, 'user');
|
||||
const workspace = path.join(fixture, 'workspace');
|
||||
fs.mkdirSync(userDirectory);
|
||||
fs.mkdirSync(workspace);
|
||||
fs.writeFileSync(path.join(userDirectory, 'settings.json'), '{"keep":"user preference"}\n');
|
||||
fs.writeFileSync(path.join(workspace, 'owned.txt'), 'existing user work\n');
|
||||
before = snapshot(fixture);
|
||||
const run = (args, direct = false) => spawnSync(process.execPath,
|
||||
[direct ? PROFILE : CLI, ...(direct ? [] : ['profile']), ...args], {
|
||||
cwd: workspace, encoding: 'utf8', timeout: 30_000, maxBuffer: 4 * 1024 * 1024,
|
||||
env: {
|
||||
PATH: process.env.PATH, SystemRoot: process.env.SystemRoot,
|
||||
HOME: userDirectory, USERPROFILE: userDirectory,
|
||||
XDG_CONFIG_HOME: path.join(userDirectory, 'config'),
|
||||
XDG_STATE_HOME: path.join(userDirectory, 'state'),
|
||||
CLAUDE_CONFIG_DIR: path.join(userDirectory, '.claude'),
|
||||
CODEX_HOME: path.join(userDirectory, '.codex'),
|
||||
...(process.env.NODE_V8_COVERAGE ? { NODE_V8_COVERAGE: process.env.NODE_V8_COVERAGE } : {}),
|
||||
},
|
||||
});
|
||||
fn(run);
|
||||
} finally {
|
||||
try {
|
||||
if (before) assert.deepStrictEqual(snapshot(fixture), before,
|
||||
'inspection must preserve user and workspace files, including failure paths');
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function success(result) {
|
||||
assert.strictEqual(result.status, 0, result.stderr || result.stdout);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.ok(['success', 'warning'].includes(payload.status));
|
||||
assert.strictEqual(typeof payload.summary, 'string');
|
||||
assert.ok(Array.isArray(payload.next_actions));
|
||||
assert.ok(Array.isArray(payload.artifacts));
|
||||
return payload;
|
||||
}
|
||||
|
||||
const tests = [
|
||||
['the dispatcher exposes read-only profile help', () => withFixture(run => {
|
||||
const result = run(['--help']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /read-only/i);
|
||||
for (const command of ['show', 'preview', 'explain']) assert.ok(result.stdout.includes(command));
|
||||
})],
|
||||
['show lists versioned definitions without claiming an active installation', () => withFixture(run => {
|
||||
const result = success(run(['show', '--json']));
|
||||
assert.deepStrictEqual(result.profiles.map(profile => profile.id).sort(), ['full@1', 'lean@1']);
|
||||
assert.strictEqual(result.activation, 'unobserved');
|
||||
})],
|
||||
['show reads one profile definition through the direct packaged entrypoint', () => withFixture(run => {
|
||||
const result = success(run(['show', 'lean@1', '--json'], true));
|
||||
assert.strictEqual(result.profile.id, 'lean@1');
|
||||
})],
|
||||
['Lean preview is deterministic and reports no observed activation', () => withFixture(run => {
|
||||
const args = ['preview', 'lean@1', '--target', 'codex', '--selection', 'auto', '--json'];
|
||||
const first = run(args);
|
||||
const payload = success(first);
|
||||
assert.deepStrictEqual(JSON.parse(run(args).stdout), payload);
|
||||
assert.strictEqual(payload.activation, 'unobserved');
|
||||
assert.ok(payload.plan);
|
||||
assert.ok(!first.stdout.includes(ROOT), 'portable output must omit local checkout path');
|
||||
})],
|
||||
['Full remains inspectable with explicit manual selection', () => withFixture(run => {
|
||||
assert.ok(success(run(['preview', 'full@1', '--target', 'claude', '--selection', 'manual', '--json'])).plan);
|
||||
})],
|
||||
['explicit includes and exclusions remain inspection only', () => withFixture(run => {
|
||||
success(run(['preview', '--target', 'codex', '--include', 'skill:security-review',
|
||||
'--exclude', 'skill:python-patterns', '--json']));
|
||||
})],
|
||||
['exact-ID explanation includes an entry and never invokes the skill', () => withFixture(run => {
|
||||
const payload = success(run(['explain', 'skill:security-review', '--target', 'codex', '--json']));
|
||||
assert.strictEqual(payload.entry.id, 'skill:security-review');
|
||||
assert.strictEqual(payload.activation, 'unobserved');
|
||||
})],
|
||||
['text output identifies estimates and unobserved runtime state', () => withFixture(run => {
|
||||
const result = run(['preview', '--target', 'codex']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /estimate/i);
|
||||
assert.match(result.stdout, /unobserved/i);
|
||||
})],
|
||||
['text error output renders terminal controls inert', () => withFixture(run => {
|
||||
const control = String.fromCharCode(27);
|
||||
const result = run(['explain', `skill:unknown${control}]52;c;example${String.fromCharCode(7)}`]);
|
||||
assert.strictEqual(result.status, 1);
|
||||
assert.ok(!result.stderr.includes(control), 'terminal escape must not reach the text output');
|
||||
assert.match(result.stderr, /\\u001b/);
|
||||
})],
|
||||
['global dry-run remains compatible with profile inspection', () => withFixture(run => {
|
||||
success(run(['preview', '--target', 'codex', '--dry-run', '--json']));
|
||||
})],
|
||||
['global dry-run is ignored at every argument position without changing parsed controls', () => {
|
||||
const { parseArgs } = require('../../scripts/profile');
|
||||
for (const args of [
|
||||
['show', 'lean@1', '--json'],
|
||||
['preview', 'lean@1', '--target', 'codex', '--selection', 'auto',
|
||||
'--include', 'skill:security-review', '--exclude', 'skill:python-patterns', '--json'],
|
||||
['explain', 'skill:ecc-guide', '--target', 'codex', '--json'],
|
||||
]) {
|
||||
const expected = parseArgs(args);
|
||||
for (let index = 0; index <= args.length; index++) {
|
||||
const invocation = [...args.slice(0, index), '--dry-run', ...args.slice(index)];
|
||||
const before = [...invocation];
|
||||
assert.deepStrictEqual(parseArgs(invocation), expected, invocation.join(' '));
|
||||
assert.deepStrictEqual(invocation, before, 'parsing must preserve caller arguments');
|
||||
}
|
||||
assert.deepStrictEqual(parseArgs(['--dry-run', ...args, '--dry-run']), expected);
|
||||
}
|
||||
}],
|
||||
['package includes the direct profile entrypoint and public schemas', () => {
|
||||
const { files } = require('../../package.json');
|
||||
assert.ok(files.includes('scripts/profile.js'));
|
||||
assert.ok(files.includes('schemas/'));
|
||||
assert.ok(files.includes('manifests/'));
|
||||
}],
|
||||
];
|
||||
|
||||
for (const args of [
|
||||
['show', 'lean@1', '--json'],
|
||||
['preview', 'lean@1', '--target', 'codex', '--selection', 'auto', '--json'],
|
||||
['explain', 'skill:ecc-guide', '--target', 'codex', '--json'],
|
||||
]) {
|
||||
tests.push([`leading global dry-run preserves ${args[0]} through both CLI entrypoints`, () => withFixture(run => {
|
||||
const expected = success(run(args));
|
||||
for (const direct of [false, true]) {
|
||||
const observed = success(run(['--dry-run', ...args], direct));
|
||||
assert.deepStrictEqual(observed, expected);
|
||||
assert.strictEqual(observed.activation, 'unobserved');
|
||||
}
|
||||
})]);
|
||||
}
|
||||
|
||||
for (const args of [
|
||||
['use', 'lean@1'],
|
||||
['--dry-run', 'use', 'lean@1'],
|
||||
['show', 'unknown@1'],
|
||||
['preview', '--target', 'unknown-host'],
|
||||
['preview', '--selection', 'eager'],
|
||||
['preview', '--target'],
|
||||
['preview', '--target', '--json'],
|
||||
['preview', '--target', 'codex', '--target', 'claude'],
|
||||
['preview', '--include', 'skill:missing-workflow'],
|
||||
['preview', '--include', '../../outside'],
|
||||
['preview', '--hooks', 'strict'],
|
||||
['--dry-run', 'preview', '--hooks', 'strict'],
|
||||
['show', '--include', 'skill:security-review'],
|
||||
['explain'],
|
||||
['explain', 'skill:missing-workflow'],
|
||||
['explain', 'skill:ecc-guide', 'extra'],
|
||||
]) {
|
||||
tests.push([`rejects unsupported or malformed input: ${args.join(' ')}`, () => withFixture(run => {
|
||||
const result = run([...args, '--json']);
|
||||
assert.notStrictEqual(result.status, 0);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.strictEqual(payload.status, 'error');
|
||||
assert.doesNotMatch(payload.summary, /Cannot find module|Require stack/,
|
||||
'validation must fail for the request, not a missing implementation');
|
||||
assert.ok(payload.next_actions.length > 0);
|
||||
assert.strictEqual(payload.activation, 'unobserved');
|
||||
})]);
|
||||
}
|
||||
|
||||
function main() {
|
||||
let passed = 0;
|
||||
for (const [name, test] of tests) {
|
||||
try { test(); passed++; console.log(` PASS ${name}`); }
|
||||
catch (error) { console.error(` FAIL ${name}: ${error.message}`); }
|
||||
}
|
||||
console.log(`\nPassed: ${passed}\nFailed: ${tests.length - passed}`);
|
||||
process.exitCode = passed === tests.length ? 0 : 1;
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
module.exports = { main };
|
||||
+43
-12
@@ -78,6 +78,35 @@ function runSetup(fixture, args, options = {}) {
|
||||
function quoteShellArgument(value) {
|
||||
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
// Answer only after the PTY displays a prompt. Fixed-delay pipes can deliver
|
||||
// blank defaults and EOF before the wizard creates its readline interface.
|
||||
function driveInteractiveTerminal() {
|
||||
const { spawn } = require('child_process');
|
||||
const { pseudoTerminalCommand, answers } = JSON.parse(process.argv[1]);
|
||||
// Node pipes are sockets on macOS; script requires a real pipe for stdin.
|
||||
const child = spawn('sh', ['-c', `cat | ${pseudoTerminalCommand}`], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
let pending = '';
|
||||
let answerIndex = 0;
|
||||
child.stdout.on('data', chunk => {
|
||||
process.stdout.write(chunk);
|
||||
pending += chunk.toString('utf8');
|
||||
const prompt = /Choose(?: \[\d+\])?: |\[y\/N\] /.exec(pending);
|
||||
if (!prompt) return;
|
||||
pending = pending.slice(prompt.index + prompt[0].length);
|
||||
if (answerIndex >= answers.length) { child.stdin.end(); return; }
|
||||
const answer = answers[answerIndex++];
|
||||
child.stdin.write(answer === '\u0004' ? answer : `${answer}\n`);
|
||||
if (answerIndex === answers.length) child.stdin.end();
|
||||
});
|
||||
child.stderr.on('data', chunk => process.stderr.write(chunk));
|
||||
child.stdin.on('error', error => {
|
||||
if (error.code !== 'EPIPE') { process.stderr.write(error.message); process.exitCode = 1; }
|
||||
});
|
||||
child.on('error', error => { process.stderr.write(error.message); process.exitCode = 1; });
|
||||
child.on('close', code => { process.exitCode = code ?? 1; });
|
||||
}
|
||||
|
||||
function runInteractiveEccSetup(fixture, options = {}) {
|
||||
if (process.platform === 'win32') {
|
||||
return null;
|
||||
@@ -85,12 +114,18 @@ function runInteractiveEccSetup(fixture, options = {}) {
|
||||
|
||||
const args = options.args || ['--dry-run'];
|
||||
const answers = options.answers || ['3', '3'];
|
||||
const command = [
|
||||
const setupCommand = [
|
||||
process.execPath,
|
||||
eccScript,
|
||||
'setup',
|
||||
...args,
|
||||
];
|
||||
const command = options.delayedStartup
|
||||
? [process.execPath, '-e', `setTimeout(() => {
|
||||
const result = require('child_process').spawnSync(process.argv[1], process.argv.slice(2), { stdio: 'inherit' });
|
||||
process.exitCode = result.status ?? 1;
|
||||
}, 1250);`, ...setupCommand]
|
||||
: setupCommand;
|
||||
const scriptArgs = process.platform === 'darwin'
|
||||
? ['-q', '-e', '/dev/null', ...command]
|
||||
: [
|
||||
@@ -100,16 +135,11 @@ function runInteractiveEccSetup(fixture, options = {}) {
|
||||
command.map(quoteShellArgument).join(' '),
|
||||
'/dev/null',
|
||||
];
|
||||
const pseudoTerminalCommand = ['script', ...scriptArgs]
|
||||
.map(quoteShellArgument)
|
||||
.join(' ');
|
||||
const answerCommands = answers
|
||||
.map(answer => `sleep 0.5; printf '%s\\n' ${quoteShellArgument(answer)}`)
|
||||
.join('; ');
|
||||
|
||||
return spawnSync('sh', [
|
||||
'-c',
|
||||
`(${answerCommands}; sleep 0.1) | ${pseudoTerminalCommand}`,
|
||||
const pseudoTerminalCommand = ['script', ...scriptArgs].map(quoteShellArgument).join(' ');
|
||||
return spawnSync(process.execPath, [
|
||||
'-e',
|
||||
`(${driveInteractiveTerminal.toString()})();`,
|
||||
JSON.stringify({ pseudoTerminalCommand, answers }),
|
||||
], {
|
||||
cwd: fixture.projectRoot,
|
||||
env: {
|
||||
@@ -386,8 +416,8 @@ test('setup automatically migrates an existing install to the selected scope and
|
||||
const calls = readCalls(fixture);
|
||||
assert.ok(calls.some(argv => (
|
||||
argv.join(' ') === 'plugin install ecc@ecc --scope user'
|
||||
+ ' --config hooks_enabled=true --config hook_profile=minimal'
|
||||
)));
|
||||
assert.ok(calls.every(argv => !argv.includes('--config')));
|
||||
assert.ok(calls.some(argv => (
|
||||
argv.join(' ') === 'plugin uninstall ecc@ecc --scope local --keep-data'
|
||||
)));
|
||||
@@ -914,6 +944,7 @@ test('interactive defaults preserve an existing install scope and hook preferenc
|
||||
const result = runInteractiveEccSetup(fixture, {
|
||||
args: ['--dry-run'],
|
||||
answers: ['', ''],
|
||||
delayedStartup: true,
|
||||
});
|
||||
assert.ifError(result.error);
|
||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
|
||||
Reference in New Issue
Block a user