fix: close release review gaps and expose failing CI suites

This commit is contained in:
haelyra
2026-09-07 17:57:26 -04:00
parent ba3a64a2c5
commit 14e731c6d5
12 changed files with 572 additions and 43 deletions
+26
View File
@@ -106,3 +106,29 @@ after the guided-setup integration repair. Signing remains unavailable locally.
Pending final hosted validation, signed tag, publication, registry
integrity readback, and clean lifecycle canaries. This document does not claim
that 2.2.1 has shipped.
## Resumed verification, September 7
The secure GitHub gateway authenticated as an authorized repository maintainer.
All GitHub API requests in this continuation use that gateway. No local
credential inspection or signing-key discovery is part of this continuation.
The v2.2.1 tag and release are absent; npm returns E404 for 2.2.1 and still
reports latest 2.2.0.
The ba3a64a2 hosted run passed coverage, lint, CodeQL, and Linux tests, but nine
Windows test jobs failed. Gateway downloads for both job logs and test artifacts
returned HTTP 401 from redirected storage. Check metadata confirms failures
occur during tests after successful dependency installation. Failed-suite
annotations now expose bounded diagnostic context through the checks API.
The runner also counts subprocess failure when a suite prints `Failed: 0`.
Seven isolated runner regressions pass.
Follow-up review reproduced additional release defects. Ordered JSON merges
to one Kimi destination were collapsed by destination-only preview indexing;
operation-specific previews preserve the supported merge sequence (24 focused
tests pass). Array-form Claude commands now receive the same plugin-root
materialization as strings, including rejection of unresolved reads (seven new
and 36 existing settings tests pass). Static PowerShell alias and stdin values
are resolved conservatively, with independent review covering mixed named and
positional alias arguments. Hosted verification on the final patch remains
required before merge or release.
+3 -2
View File
@@ -93,8 +93,9 @@ and signing evidence are tracked separately in the release checklist.
## Upgrade
Install or update the published package, then run the same ECC command path you
already use:
After the release workflow publishes 2.2.1 and verifies registry integrity,
install or update the package, then run the same ECC command path you already
use. Until publication completes, the exact-version command below returns E404.
```bash
npm install -g ecc-universal@2.2.1
+8 -2
View File
@@ -255,6 +255,10 @@ function resolveManagedHookCommands(managedHooks, targetRoot) {
const encodedRoot = Buffer.from(targetRoot, 'utf8').toString('base64');
const rootExpression = `Buffer.from('${encodedRoot}','base64').toString('utf8')`;
const resolveCommand = command => {
// Leave invalid entries intact so managed-hook validation reports them.
if (typeof command !== 'string') {
return command;
}
const resolved = command
.split(PLUGIN_ROOT_ENV_PROLOGUE)
.join(`var e=${rootExpression};`);
@@ -273,9 +277,11 @@ function resolveManagedHookCommands(managedHooks, targetRoot) {
...entry,
hooks: entry.hooks.map(hook => ({
...hook,
...(typeof hook.command === 'string'
...(typeof hook.command === 'string' || Array.isArray(hook.command)
? {
command: resolveCommand(hook.command),
command: Array.isArray(hook.command)
? hook.command.map(resolveCommand)
: resolveCommand(hook.command),
}
: {}),
})),
+13 -4
View File
@@ -373,9 +373,12 @@ async function applyPreflightedManagedPlan(entry) {
: preflightManagedPlan(entry.preview.plan);
const ownedDestinations = new Set(preview.ownershipSnapshot.destinations);
let expectedStateFingerprint = preview.ownershipSnapshot.stateFingerprint;
const expectedOperations = new Map(preview.operations.map(operation => [
canonicalPath(operation.destinationPath), operation,
// Several ordered JSON merges may share one destination. Preserve each
// operation's preview instead of collapsing that sequence to one path entry.
const expectedOperations = new Map(preview.plan.operations.map((operation, index) => [
operation, preview.operations[index],
]));
const writtenDestinations = new Set();
const assertStateUnchanged = () => (
assertInstallStateUnchanged(preview.plan, expectedStateFingerprint)
);
@@ -385,12 +388,17 @@ async function applyPreflightedManagedPlan(entry) {
};
const assertOperationUnchanged = operation => {
const destination = canonicalPath(operation.destinationPath);
const expected = expectedOperations.get(destination);
const expected = expectedOperations.get(operation);
const currentClassification = classifyManagedOperation(operation, ownedDestinations);
const expectedClassification = operation.kind === 'merge-json'
&& writtenDestinations.has(destination)
? 'managed-json-update'
: expected && expected.classification;
if (
!expected
|| expected.kind !== operation.kind
|| expected.classification !== currentClassification
|| canonicalPath(expected.destinationPath) !== destination
|| expectedClassification !== currentClassification
) {
throw new Error(
`Refusing to write ${operation.destinationPath}: destination changed after Kimi preflight.`
@@ -412,6 +420,7 @@ async function applyPreflightedManagedPlan(entry) {
assertStateUnchanged();
const destination = assertOperationUnchanged(operation);
ownedDestinations.add(destination);
writtenDestinations.add(destination);
},
beforeInstallStateWrite: prepareInstallStateWrite,
});
+93 -27
View File
@@ -58,6 +58,21 @@ const START_PROCESS_SWITCH_PARAMETERS = new Set([
'usenewenvironment',
'wait',
]);
const ALIAS_VALUE_PARAMETERS = new Set([
'name', 'value', 'description', 'option', 'scope',
'erroraction', 'warningaction', 'informationaction', 'progressaction',
'errorvariable', 'warningvariable', 'informationvariable',
'outvariable', 'outbuffer', 'pipelinevariable',
]);
const ALIAS_SWITCH_PARAMETERS = new Set([
'force', 'passthru', 'whatif', 'confirm', 'verbose', 'debug',
]);
const ALIAS_PARAMETER_ABBREVIATIONS = Object.freeze({
ea: 'erroraction', wa: 'warningaction', infa: 'informationaction', proga: 'progressaction',
ev: 'errorvariable', wv: 'warningvariable', iv: 'informationvariable',
ov: 'outvariable', ob: 'outbuffer', pv: 'pipelinevariable',
wi: 'whatif', cf: 'confirm', vb: 'verbose', db: 'debug',
});
const MAX_SCAN_DEPTH = 4;
const MAX_CONTEXT_LENGTH = 4096;
const DYNAMIC_EXECUTION_MARKER = '__ecc_dynamic_execution__';
@@ -1225,16 +1240,22 @@ function addNestedScan(payload, depth, findings, analysis, options = {}, scanSta
scanPowerShell(payload, depth + 1, findings, analysis, options, scanState);
}
function staticPipelineInput(tokens) {
function staticTokenValue(tokens, index, state, findings, inline = false) {
const value = inline ? parameterValue(tokens[index]) : tokens[index];
const quoteKind = inline ? tokens.inlineValueQuoteKinds?.[index] : tokens.quoteKinds?.[index];
if (quoteKind === "'") return value;
const source = inline
? parameterValue(tokens.tokenSources?.[index] || tokens[index])
: tokens.tokenSources?.[index] ?? value;
return expandStaticDoubleQuotedString(source, state, findings);
}
function staticPipelineInput(tokens, state, findings) {
if (!tokens || tokens.length === 0) return null;
if (tokens.length === 1) {
const value = String(tokens[0] || '');
return value || null;
}
if (tokens.length === 1) return staticTokenValue(tokens, 0, state, findings);
const command = commandBasename(tokens[0]);
if ((command === 'write-output' || command === 'echo') && tokens.length === 2) {
const value = String(tokens[1] || '');
return tokens.quotedTokens?.[1] === true || /\s/.test(value) ? value : null;
return staticTokenValue(tokens, 1, state, findings);
}
return null;
}
@@ -1272,7 +1293,7 @@ function scanNestedPowerShell(tokens, depth, findings, analysis, scanState, upst
let payload = inlinePayload
? [inlinePayload, ...tokens.slice(index + 1)].join(' ')
: tokens.slice(index + 1).join(' ');
const pipelinePayload = payload === '-' ? staticPipelineInput(upstreamTokens) : null;
const pipelinePayload = payload === '-' ? staticPipelineInput(upstreamTokens, scanState, findings) : null;
const payloadIndex = index + 1;
const inlineQuoteKind = tokens.inlineValueQuoteKinds?.[index];
if (inlinePayload && inlineQuoteKind !== "'") {
@@ -1754,26 +1775,70 @@ function scanScriptBlockConsumer(tokens, quotedTokens, findings, state) {
}
}
function staticAliasDefinition(tokens, quotedTokens = []) {
let name = null;
let value = null;
function aliasParameterName(token) {
const name = String(token).replace(/^-+/, '').split(':')[0].toLowerCase();
if (Object.hasOwn(ALIAS_PARAMETER_ABBREVIATIONS, name)) return ALIAS_PARAMETER_ABBREVIATIONS[name];
const parameters = [...ALIAS_VALUE_PARAMETERS, ...ALIAS_SWITCH_PARAMETERS];
if (parameters.includes(name)) return name;
const matches = parameters.filter(parameter => name && parameter.startsWith(name));
return matches.length === 1 ? matches[0] : null;
}
function aliasArguments(tokens, quotedTokens) {
const named = new Map();
const positional = [];
let ambiguous = false;
for (let index = 1; index < tokens.length; index += 1) {
const token = tokens[index];
if (!quotedTokens[index] && isParameterPrefix(token, 'name')) {
name = parameterValue(token) || tokens[++index] || null;
} else if (!quotedTokens[index] && isParameterPrefix(token, 'value')) {
value = parameterValue(token) || tokens[++index] || null;
} else if (!String(token).startsWith('-')) {
positional.push(token);
const token = String(tokens[index]);
if (quotedTokens[index] || !token.startsWith('-')) {
positional.push({ index, inline: false });
if (!quotedTokens[index] && token.startsWith('@')) ambiguous = true;
continue;
}
const parameter = aliasParameterName(token);
if (!parameter) {
ambiguous = true;
continue;
}
if (named.has(parameter)) ambiguous = true;
const inline = token.includes(':');
const argument = { index: ALIAS_VALUE_PARAMETERS.has(parameter) && !inline ? ++index : index, inline };
if (ALIAS_VALUE_PARAMETERS.has(parameter) && tokens[argument.index] === undefined) ambiguous = true;
named.set(parameter, argument);
// Option accepts a comma-separated array; its continuation belongs to the
// named parameter rather than the remaining positional name/value slots.
if (parameter === 'option') {
while (index + 1 < tokens.length && !quotedTokens[index] &&
(String(tokens[index]).endsWith(',') || String(tokens[index + 1]).startsWith(','))) index += 1;
}
}
name ||= positional[0] || null;
value ||= positional[1] || null;
if (!/^[A-Za-z_][\w-]*$/.test(name || '') || !/^[A-Za-z_][\w./\\-]*$/.test(value || '')) {
return null;
}
return { name: name.toLowerCase(), value };
return { named, positional, ambiguous };
}
function staticAliasDefinitions(tokens, quotedTokens, state) {
const args = aliasArguments(tokens, quotedTokens);
// Definitions stay inert. Uncertain binding is gated only when a candidate
// alias is invoked, without allowing auxiliary values to hide its target.
const unresolved = new Set();
const resolve = argument => argument
? staticTokenValue(tokens, argument.index, state, unresolved, argument.inline)
: null;
let positionalIndex = 0;
const nameArgument = args.named.get('name') || args.positional[positionalIndex++];
const valueArgument = args.named.get('value') || args.positional[positionalIndex++];
const name = resolve(nameArgument);
const value = resolve(valueArgument);
const ambiguous = args.ambiguous || positionalIndex < args.positional.length;
const possibleNames = args.named.has('value') ? args.positional : args.positional.slice(0, -1);
const names = ambiguous && !args.named.has('name')
? [name, ...possibleNames.map(resolve)]
: [name];
const target = ambiguous || value === null || /^@/.test(value)
? DYNAMIC_EXECUTION_MARKER
: value;
if (!/^[A-Za-z_][\w./\\-]*$/.test(target || '')) return [];
return [...new Set(names.filter(candidate => /^[A-Za-z_][\w-]*$/.test(candidate || '')))]
.map(candidate => ({ name: candidate.toLowerCase(), value: target }));
}
function scanInvokeScriptCalls(source, unquoted, depth, findings, analysis, state) {
@@ -1862,9 +1927,10 @@ function scanPowerShell(command, depth, findings, analysis = null, options = {},
state
);
}
if (commandName === 'set-alias' || commandName === 'new-alias') {
const definition = staticAliasDefinition(tokens, executable.quotedTokens);
if (definition) state.aliases.set(definition.name, definition.value);
if (['set-alias', 'new-alias', 'sal', 'nal'].includes(commandName)) {
for (const definition of staticAliasDefinitions(tokens, executable.quotedTokens, state)) {
state.aliases.set(definition.name, definition.value);
}
}
const classInvocation = commandName.match(/^\[([a-z_][\w-]*)\]::/i);
if (classInvocation) recordInvocation(state, `__class__:${classInvocation[1].toLowerCase()}`);
+112
View File
@@ -0,0 +1,112 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const source = fs.readFileSync(path.join(__dirname, '..', 'run-all.js'), 'utf8');
function run(result, filename = 'sample.test.js', actions = true) {
const logs = [];
const exit = {};
let status;
let spawns = 0;
const fakeProcess = {
env: actions ? { GITHUB_ACTIONS: 'true' } : {},
exit(code) { status = code; throw exit; },
};
const fakeFs = {
readdirSync: () => [{
name: filename,
isDirectory: () => false,
isFile: () => true,
}],
existsSync: () => true,
};
try {
vm.runInNewContext(source, {
__dirname: path.resolve('/virtual/tests'),
process: fakeProcess,
console: { log: (...args) => logs.push(args.join(' ')) },
require(name) {
if (name === 'fs') return fakeFs;
if (name === 'path') return path;
if (name === 'child_process') return {
spawnSync() { spawns += 1; return result; },
};
throw new Error(`Unexpected dependency: ${name}`);
},
});
} catch (error) {
if (error !== exit) throw error;
}
assert.strictEqual(spawns, 1);
return { status, logs, annotations: logs.filter(line => line.startsWith('::error ')) };
}
const tests = [
['nonzero exit overrides a zero-failure summary', () => {
const result = run({ status: 1, stdout: 'Passed: 2, Failed: 0', stderr: 'Error: late crash' });
assert.strictEqual(result.status, 1);
assert.strictEqual(result.annotations.length, 1);
assert.match(result.annotations[0], /file=tests\/sample.test.js/);
assert.match(result.annotations[0], /status 1.*Error: late crash/);
assert.ok(result.logs.includes('Error: late crash'));
}],
['startup errors always count as failures and annotate their cause', () => {
const result = run({ status: null, stdout: 'Failed: 0', error: new Error('spawn node ENOENT') });
assert.strictEqual(result.status, 1);
assert.strictEqual(result.annotations.length, 1);
assert.match(result.annotations[0], /failed to start.*spawn node ENOENT/);
}],
['annotation properties and messages escape workflow command characters', () => {
const result = run({ status: null, error: new Error('100% broken\r\nnext line') }, 'sample%,:.test.js');
assert.strictEqual(result.annotations.length, 1);
assert.ok(result.annotations[0].includes('file=tests/sample%25%2C%3A.test.js'));
assert.ok(result.annotations[0].includes('100%25 broken%0D%0Anext line'));
assert.ok(!result.annotations[0].includes('\n'));
assert.ok(!result.annotations[0].includes('\r'));
}],
['failure summaries annotate concise context even with a successful exit', () => {
const output = `${'routine log\n'.repeat(100)}FAIL regression example\nPassed: 2, Failed: 1`;
const result = run({ status: 0, stdout: output });
assert.strictEqual(result.status, 1);
assert.strictEqual(result.annotations.length, 1);
assert.match(result.annotations[0], /FAIL regression example/);
assert.ok(result.annotations[0].length < 1500);
assert.ok(!result.annotations[0].includes('routine log'));
assert.ok(result.logs.includes(output));
assert.ok(result.logs.some(line => /Failed:\s+1\s/.test(line)));
}],
['signals fail even when no summary was printed', () => {
const result = run({ status: null, signal: 'SIGTERM' });
assert.strictEqual(result.status, 1);
assert.match(result.annotations[0], /SIGTERM/);
}],
['healthy suites preserve successful totals and emit no annotation', () => {
const result = run({ status: 0, stdout: 'Passed: 3, Failed: 0' });
assert.strictEqual(result.status, 0);
assert.deepStrictEqual(result.annotations, []);
assert.ok(result.logs.some(line => /Passed:\s+3\s/.test(line)));
}],
['local failures retain console diagnostics without workflow annotations', () => {
const result = run({ status: 1, stderr: 'Error: local failure' }, 'sample.test.js', false);
assert.strictEqual(result.status, 1);
assert.deepStrictEqual(result.annotations, []);
assert.ok(result.logs.includes('Error: local failure'));
}],
];
let failed = 0;
for (const [name, test] of tests) {
try {
test();
console.log(`PASS ${name}`);
} catch (error) {
failed += 1;
console.error(`FAIL ${name}\n${error.stack || error.message}`);
}
}
console.log(`Passed: ${tests.length - failed}, Failed: ${failed}`);
process.exitCode = failed ? 1 : 0;
+14
View File
@@ -3020,6 +3020,20 @@ function runTests() {
"$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command \"$payload\"",
"$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command \"Write-Output ready; $payload\"",
"$payload='Remove-Item'; pwsh -Command $payload -Force C:/tmp/demo",
"$cmd='Remove-Item'; Set-Alias zap $cmd; zap -Force C:/tmp/demo",
"$cmd='Remove-Item'; Set-Alias -Name zap $cmd; zap -Force C:/tmp/demo",
"$cmd='Remove-Item'; Set-Alias -Scope Global -Name zap $cmd; zap -Force C:/tmp/demo",
"$cmd='Remove-Item'; New-Alias -Description demo -Name zap $cmd; zap -Force C:/tmp/demo",
"$cmd='Remove-Item'; sal -Option AllScope -Name zap $cmd; zap -Force C:/tmp/demo",
'Set-Alias -Unknown demo -Name zap Write-Output; zap ok',
'Set-Alias -Name zap Remove-Item; zap -Force C:/tmp/demo',
'Set-Alias -Name zap $cmd; zap -Force C:/tmp/demo',
"$cmd='Remove-Item'; Set-Alias -Value $cmd zap; zap -Force C:/tmp/demo",
"$payload='Remove-Item -Force C:/tmp/demo'; $payload | pwsh -Command -",
"$payload='Remove-Item -Force C:/tmp/demo'; Write-Output $payload | pwsh -Command -",
"Set-Alias zap $cmd; zap -Force C:/tmp/demo; $cmd='Write-Output'",
"$payload | pwsh -Command -; $payload='Write-Output ok'",
'pwsh -Command "Write-Output ready; $runtimePayload"',
'pwsh -Command $runtimePayload -Force C:/tmp/demo',
'Write-Output "$(Remove-Item -Force C:/tmp/demo)"',
+44
View File
@@ -247,6 +247,50 @@ async function runTests() {
command: "$payload='Remove-Item -Force C:/private/expanded-command-sentinel'; pwsh -Command \"Write-Output ready; $payload\"",
expectedRules: ['powershell.remove-item.force'],
},
{
command: "$cmd='Remove-Item'; Set-Alias zap $cmd; zap -Force C:/private/alias-command-sentinel",
expectedRules: ['powershell.remove-item.force'],
},
{
command: "$cmd='Remove-Item'; Set-Alias -Name zap $cmd; zap -Force C:/private/alias-command-sentinel",
expectedRules: ['powershell.remove-item.force'],
},
{
command: 'Set-Alias -Name zap Remove-Item; zap -Force C:/private/alias-command-sentinel',
expectedRules: ['powershell.remove-item.force'],
},
{
command: 'Set-Alias -Name zap $cmd; zap -Force C:/private/alias-command-sentinel',
expectedRules: ['powershell.dynamic-execution'],
},
{
command: "$cmd='Remove-Item'; Set-Alias -Scope Global -Name zap $cmd; zap -Force C:/private/alias-command-sentinel",
expectedRules: ['powershell.remove-item.force'],
},
{
command: "$cmd='Remove-Item'; New-Alias -Description demo -Name zap $cmd; zap -Force C:/private/alias-command-sentinel",
expectedRules: ['powershell.remove-item.force'],
},
{
command: "$cmd='Remove-Item'; sal -Option AllScope -Name zap $cmd; zap -Force C:/private/alias-command-sentinel",
expectedRules: ['powershell.remove-item.force'],
},
{
command: 'Set-Alias -Unknown demo -Name zap Write-Output; zap ok',
expectedRules: ['powershell.dynamic-execution'],
},
{
command: "$payload='Remove-Item -Force C:/private/stdin-command-sentinel'; $payload | pwsh -Command -",
expectedRules: ['powershell.remove-item.force'],
},
{
command: "Set-Alias zap $cmd; zap -Force C:/private/alias-command-sentinel; $cmd='Write-Output'",
expectedRules: ['powershell.dynamic-execution'],
},
{
command: "$payload | pwsh -Command -; $payload='Write-Output ok'",
expectedRules: ['powershell.dynamic-execution'],
},
{
command: 'pwsh -Command "Write-Output ready; $runtimePayload"',
expectedRules: ['powershell.dynamic-execution'],
+88
View File
@@ -0,0 +1,88 @@
'use strict';
const assert = require('assert');
const { spawnSync } = require('child_process');
const { materializeManagedHooks } = require('../../scripts/lib/install/claude-settings');
function config(command) {
return {
hooks: {
Stop: [{ id: 'ecc:array', hooks: [{ type: 'command', command }] }],
},
};
}
function materialize(command, root = '/opt/ecc') {
return materializeManagedHooks(config(command), root).Stop[0].hooks[0].command;
}
const tests = [
['materializes every array command without changing the source', () => {
const source = config([
'node -e "var e=process.env.CLAUDE_PLUGIN_ROOT;console.log(e)"',
'node -e "var e=process.env.CLAUDE_PLUGIN_ROOT;console.log(e)"',
'${CLAUDE_PLUGIN_ROOT}/scripts/start.js',
'--unchanged',
]);
const before = JSON.parse(JSON.stringify(source));
const command = materializeManagedHooks(source, '/opt/ecc').Stop[0].hooks[0].command;
assert.deepStrictEqual(source, before);
assert.notStrictEqual(command, source.hooks.Stop[0].hooks[0].command);
assert.ok(command.slice(0, 2).every(value => !value.includes('process.env.CLAUDE_PLUGIN_ROOT')));
assert.deepStrictEqual(command.slice(2), ['/opt/ecc/scripts/start.js', '--unchanged']);
}],
['array argv commands execute with the exact root in a clean process', () => {
const root = '/tmp/ECC space/\'"$`\\路径';
const command = materialize([
process.execPath,
'-e',
'var e=process.env.CLAUDE_PLUGIN_ROOT;process.stdout.write(e);',
], root);
const result = spawnSync(command[0], command.slice(1), {
env: {},
encoding: 'utf8',
timeout: 5000,
});
assert.ifError(result.error);
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(result.stdout, root);
}],
...[0, 1].map(index => [
`rejects an unresolved root read in array element ${index}`,
() => {
const command = ['echo first', 'echo second'];
command[index] = 'node -e "const root=process.env.CLAUDE_PLUGIN_ROOT"';
assert.throws(() => materialize(command), /Unable to resolve CLAUDE_PLUGIN_ROOT/);
},
]),
['rejects a remaining root read after resolving an array prologue', () => {
assert.throws(() => materialize([
'var e=process.env.CLAUDE_PLUGIN_ROOT;console.log(process.env.CLAUDE_PLUGIN_ROOT);',
]), /Unable to resolve CLAUDE_PLUGIN_ROOT/);
}],
['retains supported root assignments in array commands', () => {
const command = materialize([
'var e=process.env.CLAUDE_PLUGIN_ROOT;process.env.CLAUDE_PLUGIN_ROOT=e;',
]);
assert.ok(!command[0].includes('var e=process.env.CLAUDE_PLUGIN_ROOT;'));
assert.ok(command[0].includes('process.env.CLAUDE_PLUGIN_ROOT=e;'));
}],
['invalid arrays still fail command validation', () => {
for (const command of [[], ['node', null], ['node', 3], ['node', ' ']]) {
assert.throws(() => materialize(command), /invalid command/);
}
}],
];
let failed = 0;
for (const [name, run] of tests) {
try {
run();
console.log(` PASS ${name}`);
} catch (error) {
failed += 1;
console.error(` FAIL ${name}\n ${error.stack || error.message}`);
}
}
console.log(`\nResults: Passed: ${tests.length - failed}, Failed: ${failed}`);
process.exitCode = failed > 0 ? 1 : 0;
+38
View File
@@ -601,6 +601,44 @@ function writeManagedState(plan, overrides = {}) {
}
});
for (const existing of [false, true]) {
await test(`applies ordered JSON merges to the same ${existing ? 'existing' : 'new'} destination`, async () => {
const root = tempDir('ecc-guided-repeated-json-');
const projection = require('../../scripts/lib/install-state-store-sync');
const originalProjection = projection.projectCanonicalInstallState;
projection.projectCanonicalInstallState = async () => ({ status: 'projected' });
try {
const destination = path.join(root, '.kimi-code', 'mcp.json');
if (existing) writeFile(destination, JSON.stringify({ userSetting: true }));
const plan = managedPlan(root, [
stateOperation(destination, {
kind: 'merge-json',
mergePayload: { servers: { first: { command: 'first' } }, sequence: 'first' },
strategy: 'merge-json',
}),
stateOperation(destination, {
kind: 'merge-json',
mergePayload: { servers: { second: { command: 'second' } }, sequence: 'second' },
strategy: 'merge-json',
}),
]);
const result = await applyMultiHarnessPlan({
harnesses: [{ id: 'kimi', preview: preflightManagedPlan(plan) }],
request: { harnesses: ['kimi'] },
});
assert.strictEqual(result.status, 'complete', JSON.stringify(result.failure));
assert.deepStrictEqual(JSON.parse(fs.readFileSync(destination, 'utf8')), {
...(existing ? { userSetting: true } : {}),
servers: { first: { command: 'first' }, second: { command: 'second' } },
sequence: 'second',
});
} finally {
projection.projectCanonicalInstallState = originalProjection;
fs.rmSync(root, { recursive: true, force: true });
}
});
}
await test('refuses conflicting JSON created after preview but before apply', async () => {
const root = tempDir('ecc-guided-late-json-collision-');
try {
@@ -206,6 +206,109 @@ test('does not resolve earlier invocations from later scalar assignments', () =>
expectSafe('$payload = "Write-Output ok"; pwsh -Command "$payload"');
});
test('resolves scalar values supplied to aliases and shell stdin', () => {
for (const command of [
"$cmd='Remove-Item'; Set-Alias zap $cmd; zap -Force C:/tmp/demo",
"$cmd='Remove-Item'; New-Alias -Name zap -Value $cmd; zap -Force C:/tmp/demo",
"$cmd='Remove-Item'; Set-Alias -Name zap -Value:$cmd; zap -Force C:/tmp/demo",
'$cmd=\'Remove-Item\'; Set-Alias zap "$cmd"; zap -Force C:/tmp/demo',
"$payload='Remove-Item -Force C:/tmp/demo'; $payload | pwsh -Command -",
"$payload='Remove-Item -Force C:/tmp/demo'; Write-Output $payload | pwsh -Command -",
'$payload=\'Remove-Item -Force C:/tmp/demo\'; "$payload" | pwsh -Command -',
'$payload=\'Remove-Item -Force C:/tmp/demo\'; Write-Output "$payload" | pwsh -Command -',
]) {
expectRules(command, [RULES.REMOVE_FORCE]);
}
expectSafe('Set-Alias zap $runtimeCommand');
expectSafe("$cmd='Write-Output'; Set-Alias zap $cmd; zap ok");
expectSafe("$payload='Write-Output ok'; $payload | pwsh -Command -");
expectSafe("$payload='Remove-Item -Force C:/tmp/demo'; '$payload' | pwsh -Command -");
expectSafe("$cmd='Remove-Item'; Set-Alias zap '$cmd'; zap -Force C:/tmp/demo");
});
test('binds remaining alias positional arguments after named parameters', () => {
for (const definition of [
'Set-Alias -Name zap $cmd',
'New-Alias -Name:zap $cmd',
'Set-Alias -Value $cmd zap',
'New-Alias zap -Value:$cmd',
]) {
expectRules(`$cmd='Remove-Item'; ${definition}; zap -Force C:/tmp/demo`, [
RULES.REMOVE_FORCE,
]);
expectRules(`${definition}; zap -Force C:/tmp/demo`, [RULES.DYNAMIC_EXECUTION]);
expectRules(`${definition}; zap -Force C:/tmp/demo; $cmd='Write-Output'`, [
RULES.DYNAMIC_EXECUTION,
]);
expectSafe(`$cmd='Write-Output'; ${definition}; zap ok`);
expectSafe(definition);
}
expectRules('Set-Alias -Name zap Remove-Item; zap -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
expectRules('Set-Alias -Value Remove-Item zap; zap -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
expectSafe("$cmd='Remove-Item'; Set-Alias -Name zap '$cmd'; zap -Force C:/tmp/demo");
});
test('binds alias auxiliary parameters independently of their layout', () => {
const parameters = [
'-Scope Global', '-Sc:Global', '-Description demo', '-Desc:demo',
'-Option AllScope', '-Opt:AllScope', '-Option ReadOnly, Private',
'-Force', '-Fo:$false', '-PassThru', '-Pass:$false',
'-Verbose', '-vb:$false', '-Debug', '-db:$false',
'-Confirm:$false', '-cf:$false', '-WhatIf:$false', '-wi:$false',
'-ErrorAction Stop', '-ea:Stop', '-WarningAction Continue', '-wa:Continue',
'-InformationAction Continue', '-infa:Continue', '-ProgressAction Continue',
'-proga:Continue', '-ErrorVariable errors', '-ev:errors',
'-WarningVariable warnings', '-wv:warnings', '-InformationVariable info', '-iv:info',
'-OutVariable output', '-ov:output', '-OutBuffer 1', '-ob:1',
'-PipelineVariable item', '-pv:item',
];
for (const command of ['Set-Alias', 'New-Alias', 'sal', 'nal']) {
for (const parameter of parameters) {
for (const args of [
`${parameter} -Name zap $cmd`,
`-Name zap ${parameter} $cmd`,
`-Name zap $cmd ${parameter}`,
`${parameter} zap -Value $cmd`,
`${parameter} zap $cmd`,
]) {
const definition = `${command} ${args}`;
expectRules(`$cmd='Remove-Item'; ${definition}; zap -Force C:/tmp/demo`, [RULES.REMOVE_FORCE]);
expectRules(`${definition}; zap -Force C:/tmp/demo`, [RULES.DYNAMIC_EXECUTION]);
expectSafe(`$cmd='Write-Output'; ${definition}; zap ok`);
expectSafe(definition);
}
}
}
expectRules('Set-Alias -Scope Global -Name zap Remove-Item; zap -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
});
test('gates invoked aliases with unsupported or ambiguous parameter binding', () => {
for (const definition of [
'Set-Alias -Unknown demo -Name zap Write-Output',
'Set-Alias -Unknown demo zap Write-Output',
'Set-Alias -Name zap -V Write-Output',
'Set-Alias -Name zap -Option AllScope extra Write-Output',
'Set-Alias -Name zap @parameters',
]) {
expectRules(`${definition}; zap ok`, [RULES.DYNAMIC_EXECUTION]);
expectSafe(`${definition}; Write-Output ok`);
}
});
test('gates unresolved aliases and stdin without using later or reassigned scalars', () => {
for (const command of [
'Set-Alias zap $cmd; zap -Force C:/tmp/demo',
"Set-Alias zap $cmd; zap -Force C:/tmp/demo; $cmd='Write-Output'",
"$cmd='Remove-Item'; Set-Alias zap $cmd; $cmd='Write-Output'; zap -Force C:/tmp/demo",
'$payload | pwsh -Command -',
'Write-Output $payload | pwsh -Command -',
"$payload | pwsh -Command -; $payload='Write-Output ok'",
"$payload='Remove-Item -Force C:/tmp/demo'; $payload | pwsh -Command -; $payload='Write-Output ok'",
]) {
expectRules(command, [RULES.DYNAMIC_EXECUTION]);
}
});
test('classifies powershell and pwsh command payloads recursively', () => {
expectRules(
'powershell -Command "Remove-Item -Recurse C:/tmp/demo"',
+30 -8
View File
@@ -43,6 +43,21 @@ function discoverTestFiles() {
.sort();
}
function escapeAnnotation(value, property = false) {
const escaped = value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
return property ? escaped.replace(/:/g, '%3A').replace(/,/g, '%2C') : escaped;
}
function annotateFailure(displayPath, reason, output) {
if (process.env.GITHUB_ACTIONS !== 'true') return;
const context = output.split(/\r?\n/)
.filter(line => /\b(?:FAIL|[A-Za-z]*Error)\b|[]/i.test(line))
.slice(0, 3)
.join('\n');
const message = [reason, context].filter(Boolean).join(': ').slice(0, 1000);
console.log(`::error file=${escapeAnnotation(`tests/${displayPath}`, true)}::${escapeAnnotation(message)}`);
}
const testFiles = discoverTestFiles();
const BOX_W = 58; // inner width between ║ delimiters
@@ -96,22 +111,29 @@ for (const testFile of testFiles) {
if (stderr) console.log(stderr);
// Parse results from combined output
const combined = stdout + stderr;
const combined = `${stdout}\n${stderr}`;
const passedMatch = combined.match(/Passed:\s*(\d+)/);
const failedMatch = combined.match(/Failed:\s*(\d+)/);
if (passedMatch) totalPassed += parseInt(passedMatch[1], 10);
if (failedMatch) totalFailed += parseInt(failedMatch[1], 10);
const reportedFailures = failedMatch ? parseInt(failedMatch[1], 10) : 0;
const processFailed = Boolean(result.error) || result.status !== 0;
totalFailed += processFailed ? Math.max(reportedFailures, 1) : reportedFailures;
let failureReason;
if (result.error) {
console.log(`${displayPath} failed to start: ${result.error.message}`);
totalFailed += failedMatch ? 0 : 1;
continue;
failureReason = `failed to start: ${result.error.message}`;
} else if (result.status !== 0) {
failureReason = result.signal
? `terminated by signal ${result.signal}`
: `exited with status ${result.status}`;
} else if (reportedFailures > 0) {
failureReason = `reported ${reportedFailures} failed tests`;
}
if (result.status !== 0) {
console.log(`${displayPath} exited with status ${result.status}`);
totalFailed += failedMatch ? 0 : 1;
if (failureReason) {
console.log(`${displayPath} ${failureReason}`);
annotateFailure(displayPath, failureReason, combined);
}
}