Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
419 lines
14 KiB
JavaScript
419 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* federated-config-key-removal.test.cjs
|
|
*
|
|
* ADR-857 deliverable F — Decision 3:
|
|
* "Uninstalling a Capability removes its config keys cleanly."
|
|
*
|
|
* Tests:
|
|
* [happy] Capability X present → its key surfaces; X removed → key gone, others intact.
|
|
* [happy] loadConfig after removing a capability no longer surfaces the key, but central
|
|
* base-config keys remain.
|
|
* [BVA] Orphaned user value for the removed key is dropped — not leaked as a phantom key.
|
|
* [negative] Removing capability 'x' does NOT drop a differently-prefixed capability's key
|
|
* (e.g. 'xy.enabled' survives when only 'x.enabled' is removed).
|
|
*/
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const { mergeFederatedConfig } = require('../msd-core/bin/lib/federated-config.cjs');
|
|
|
|
const configLoader = require('../msd-core/bin/lib/config-loader.cjs');
|
|
const {
|
|
loadConfig,
|
|
_setFederatedRegistryForTests,
|
|
_resetFederatedRegistryForTests,
|
|
} = configLoader;
|
|
|
|
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
|
|
/** Never treated as a central key — all keys federated freely. */
|
|
const neverCentral = (_key) => false;
|
|
|
|
/** Minimal well-formed boolean slice. */
|
|
function boolSlice(owner, defaultValue = true) {
|
|
return { owner, type: 'boolean', default: defaultValue, description: `Boolean key for ${owner}.` };
|
|
}
|
|
|
|
// ─── Temp project helpers (mirrors federated-config-loadconfig.test.cjs) ──────
|
|
|
|
let tmpDirs = [];
|
|
|
|
beforeEach(() => {
|
|
tmpDirs = [];
|
|
_resetFederatedRegistryForTests();
|
|
});
|
|
|
|
afterEach(() => {
|
|
_resetFederatedRegistryForTests();
|
|
for (const d of tmpDirs) {
|
|
cleanup(d);
|
|
}
|
|
});
|
|
|
|
function mkTempProject() {
|
|
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cap-removal-test-'));
|
|
tmpDirs.push(d);
|
|
fs.mkdirSync(path.join(d, '.planning', 'phases'), { recursive: true });
|
|
return d;
|
|
}
|
|
|
|
function writeConfig(dir, obj) {
|
|
fs.writeFileSync(
|
|
path.join(dir, '.planning', 'config.json'),
|
|
JSON.stringify(obj, null, 2),
|
|
'utf-8',
|
|
);
|
|
}
|
|
|
|
// ─── 1. [happy] key present → surfaces; capability removed → key gone ─────────
|
|
|
|
describe('[happy] capability X present then removed — key lifecycle', () => {
|
|
test('registry WITH capability X: x.enabled surfaces with default true', () => {
|
|
const withX = {
|
|
'x.enabled': boolSlice('cap-x', true),
|
|
};
|
|
|
|
const result = mergeFederatedConfig({
|
|
configSchema: withX,
|
|
isCentralKey: neverCentral,
|
|
userConfig: {},
|
|
});
|
|
|
|
// Positive assertion: the key is in validKeys AND values
|
|
assert.ok(
|
|
result.validKeys.includes('x.enabled'),
|
|
'x.enabled must be in validKeys when cap-x is installed',
|
|
);
|
|
assert.strictEqual(
|
|
result.values['x.enabled'],
|
|
true,
|
|
'x.enabled default must be true',
|
|
);
|
|
assert.deepEqual(result.warnings, [], 'no warnings for valid federated key');
|
|
});
|
|
|
|
test('registry WITHOUT capability X: x.enabled is absent from result', () => {
|
|
// Registry after cap-x is uninstalled — its key is no longer in configSchema
|
|
const withoutX = {};
|
|
|
|
const result = mergeFederatedConfig({
|
|
configSchema: withoutX,
|
|
isCentralKey: neverCentral,
|
|
userConfig: {},
|
|
});
|
|
|
|
assert.ok(
|
|
!result.validKeys.includes('x.enabled'),
|
|
'x.enabled must NOT be in validKeys after cap-x is removed',
|
|
);
|
|
assert.ok(
|
|
!Object.prototype.hasOwnProperty.call(result.values, 'x.enabled'),
|
|
'x.enabled must NOT appear in values after cap-x is removed',
|
|
);
|
|
assert.strictEqual(Object.keys(result.values).length, 0, 'values must be empty');
|
|
});
|
|
|
|
test('[no contamination] removing cap-x leaves cap-y.flag intact', () => {
|
|
// Before removal: both capabilities present
|
|
const withBoth = {
|
|
'x.enabled': boolSlice('cap-x', true),
|
|
'y.flag': boolSlice('cap-y', false),
|
|
};
|
|
|
|
const before = mergeFederatedConfig({
|
|
configSchema: withBoth,
|
|
isCentralKey: neverCentral,
|
|
userConfig: {},
|
|
});
|
|
|
|
assert.ok(before.validKeys.includes('x.enabled'), 'x.enabled present before removal');
|
|
assert.ok(before.validKeys.includes('y.flag'), 'y.flag present before removal');
|
|
|
|
// After removal: only cap-y remains in the registry
|
|
const withoutX = {
|
|
'y.flag': boolSlice('cap-y', false),
|
|
};
|
|
|
|
const after = mergeFederatedConfig({
|
|
configSchema: withoutX,
|
|
isCentralKey: neverCentral,
|
|
userConfig: {},
|
|
});
|
|
|
|
// x.enabled must be gone
|
|
assert.ok(
|
|
!after.validKeys.includes('x.enabled'),
|
|
'x.enabled must be absent after cap-x removal',
|
|
);
|
|
assert.ok(
|
|
!Object.prototype.hasOwnProperty.call(after.values, 'x.enabled'),
|
|
'x.enabled must not appear in values after removal',
|
|
);
|
|
|
|
// y.flag must still be present AND have the correct value
|
|
assert.ok(
|
|
after.validKeys.includes('y.flag'),
|
|
'y.flag must still be in validKeys after cap-x removal',
|
|
);
|
|
assert.strictEqual(
|
|
after.values['y.flag'],
|
|
false,
|
|
'y.flag value must remain false (its default) after cap-x removal',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── 2. [happy] loadConfig: removed capability key absent, base keys intact ───
|
|
|
|
describe('[happy] loadConfig after capability removal — base keys survive', () => {
|
|
test('cap-x present → loadConfig surfaces mytool.enabled; cap-x absent → key gone', () => {
|
|
const tmpDir = mkTempProject();
|
|
writeConfig(tmpDir, {});
|
|
|
|
// Phase A: cap-x installed
|
|
_setFederatedRegistryForTests({
|
|
configSchema: {
|
|
'mytool.enabled': boolSlice('cap-x', true),
|
|
},
|
|
});
|
|
|
|
const resultWith = loadConfig(tmpDir);
|
|
assert.ok(
|
|
typeof resultWith['mytool'] === 'object' && resultWith['mytool'] !== null,
|
|
'mytool section must exist when cap-x is installed',
|
|
);
|
|
assert.strictEqual(
|
|
resultWith['mytool']['enabled'],
|
|
true,
|
|
'mytool.enabled must be true (cap-x default)',
|
|
);
|
|
|
|
// Phase B: cap-x uninstalled — registry now empty
|
|
_resetFederatedRegistryForTests();
|
|
_setFederatedRegistryForTests({ configSchema: {} });
|
|
|
|
const resultWithout = loadConfig(tmpDir);
|
|
// Central base-config key must still be present
|
|
assert.ok(
|
|
Object.prototype.hasOwnProperty.call(resultWithout, 'model_profile'),
|
|
'model_profile (central key) must still exist after cap-x removal',
|
|
);
|
|
// Federated key must be absent — either undefined or not surfaced under 'mytool'
|
|
const myToolSection = resultWithout['mytool'];
|
|
const enabledValue = (myToolSection && typeof myToolSection === 'object')
|
|
? myToolSection['enabled']
|
|
: undefined;
|
|
assert.strictEqual(
|
|
enabledValue,
|
|
undefined,
|
|
'mytool.enabled must NOT be present after cap-x removal; got: ' + JSON.stringify(enabledValue),
|
|
);
|
|
});
|
|
|
|
test('base config keys (model_profile, research) survive capability removal', () => {
|
|
const tmpDir = mkTempProject();
|
|
writeConfig(tmpDir, { model_profile: 'fast', research: false });
|
|
|
|
// Install and then remove a synthetic capability
|
|
_setFederatedRegistryForTests({
|
|
configSchema: {
|
|
'extra.flag': boolSlice('cap-extra', true),
|
|
},
|
|
});
|
|
const before = loadConfig(tmpDir);
|
|
assert.strictEqual(before['model_profile'], 'fast', 'model_profile from user config before removal');
|
|
assert.strictEqual(before['research'], false, 'research from user config before removal');
|
|
|
|
_setFederatedRegistryForTests({ configSchema: {} });
|
|
const after = loadConfig(tmpDir);
|
|
|
|
// Central keys from user's config.json must be unchanged
|
|
assert.strictEqual(after['model_profile'], 'fast', 'model_profile must survive capability removal');
|
|
assert.strictEqual(after['research'], false, 'research must survive capability removal');
|
|
});
|
|
});
|
|
|
|
// ─── 3. [BVA] orphaned user value not surfaced after removal ──────────────────
|
|
|
|
describe('[BVA] orphaned user value is silently dropped after capability removal', () => {
|
|
test('user config sets removed key → orphaned value not surfaced as phantom', () => {
|
|
// User has 'mytool.enabled': false in their config.json
|
|
// BUT the capability is now uninstalled (not in registry configSchema)
|
|
const result = mergeFederatedConfig({
|
|
configSchema: {}, // cap-x removed — configSchema is empty
|
|
isCentralKey: neverCentral,
|
|
userConfig: { mytool: { enabled: false } }, // user value remains in file
|
|
});
|
|
|
|
// The orphaned user value must NOT leak into validKeys or values
|
|
assert.ok(
|
|
!result.validKeys.includes('mytool.enabled'),
|
|
'orphaned user key must not appear in validKeys',
|
|
);
|
|
assert.ok(
|
|
!Object.prototype.hasOwnProperty.call(result.values, 'mytool.enabled'),
|
|
'orphaned user key must not appear in values',
|
|
);
|
|
// The entire values map must be empty (no phantom keys)
|
|
assert.strictEqual(
|
|
Object.keys(result.values).length,
|
|
0,
|
|
'values must be empty when registry has no keys — got: ' + JSON.stringify(Object.keys(result.values)),
|
|
);
|
|
assert.deepEqual(result.validKeys, [], 'validKeys must be empty when registry has no keys');
|
|
});
|
|
|
|
test('user config sets removed key — top-level orphan also not surfaced', () => {
|
|
// Top-level orphan: user set 'orphan_flag' but the cap is gone
|
|
const result = mergeFederatedConfig({
|
|
configSchema: {},
|
|
isCentralKey: neverCentral,
|
|
userConfig: { orphan_flag: true },
|
|
});
|
|
|
|
assert.ok(
|
|
!Object.prototype.hasOwnProperty.call(result.values, 'orphan_flag'),
|
|
'top-level orphaned key must not appear in values',
|
|
);
|
|
assert.deepEqual(result.validKeys, []);
|
|
assert.strictEqual(Object.keys(result.values).length, 0);
|
|
});
|
|
|
|
test('loadConfig: orphaned user value in config.json not surfaced after removal', () => {
|
|
const tmpDir = mkTempProject();
|
|
// User config contains a value for a key whose capability will be removed
|
|
writeConfig(tmpDir, { orphancap: { flag: true } });
|
|
|
|
// Capability removed: inject empty registry
|
|
_setFederatedRegistryForTests({ configSchema: {} });
|
|
|
|
const result = loadConfig(tmpDir);
|
|
|
|
// The orphaned capability key must NOT be surfaced in the resolved config object.
|
|
// loadConfig extracts only known/central/federated keys into _baseConfig — any key
|
|
// whose capability has been uninstalled (configSchema: {}) must not appear in the result.
|
|
assert.ok(
|
|
!Object.prototype.hasOwnProperty.call(result, 'orphancap'),
|
|
'orphaned top-level key must not appear in resolved config when capability is removed',
|
|
);
|
|
// Central keys must remain unaffected by capability removal.
|
|
assert.ok(
|
|
Object.prototype.hasOwnProperty.call(result, 'model_profile'),
|
|
'model_profile must still be present (central key unaffected by federated removal)',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── 4. [negative] removing 'x' does NOT drop 'xy.enabled' ──────────────────
|
|
|
|
describe('[negative] prefix-adjacent key not dropped when shorter-prefix cap removed', () => {
|
|
test("removing cap 'x' (key x.enabled) does NOT remove cap 'xy' (key xy.enabled)", () => {
|
|
// Registry after 'x' is uninstalled but 'xy' remains
|
|
const registryAfterXRemoved = {
|
|
'xy.enabled': boolSlice('cap-xy', false),
|
|
};
|
|
|
|
const result = mergeFederatedConfig({
|
|
configSchema: registryAfterXRemoved,
|
|
isCentralKey: neverCentral,
|
|
userConfig: {},
|
|
});
|
|
|
|
// x.enabled must not appear (was removed)
|
|
assert.ok(
|
|
!result.validKeys.includes('x.enabled'),
|
|
'x.enabled must not appear (cap-x was uninstalled)',
|
|
);
|
|
assert.ok(
|
|
!Object.prototype.hasOwnProperty.call(result.values, 'x.enabled'),
|
|
'x.enabled must not be in values',
|
|
);
|
|
|
|
// xy.enabled MUST still appear (different capability)
|
|
assert.ok(
|
|
result.validKeys.includes('xy.enabled'),
|
|
'xy.enabled must still be present after cap-x removal',
|
|
);
|
|
assert.strictEqual(
|
|
result.values['xy.enabled'],
|
|
false,
|
|
'xy.enabled value must be false (cap-xy default), not contaminated by cap-x removal',
|
|
);
|
|
});
|
|
|
|
test("removing 'x' does not drop 'x2.enabled' (numeric suffix, distinct cap)", () => {
|
|
const registryAfterXRemoved = {
|
|
'x2.enabled': boolSlice('cap-x2', true),
|
|
};
|
|
|
|
const result = mergeFederatedConfig({
|
|
configSchema: registryAfterXRemoved,
|
|
isCentralKey: neverCentral,
|
|
userConfig: {},
|
|
});
|
|
|
|
assert.ok(
|
|
!result.validKeys.includes('x.enabled'),
|
|
'x.enabled must not appear (not in registry)',
|
|
);
|
|
assert.ok(
|
|
result.validKeys.includes('x2.enabled'),
|
|
'x2.enabled must survive — it belongs to cap-x2, not cap-x',
|
|
);
|
|
assert.strictEqual(
|
|
result.values['x2.enabled'],
|
|
true,
|
|
'x2.enabled must have its own default (true)',
|
|
);
|
|
});
|
|
|
|
test("removing 'alpha' cap does not affect 'alphabeta.flag' cap", () => {
|
|
const registryAfterAlphaRemoved = {
|
|
'alphabeta.flag': boolSlice('cap-alphabeta', false),
|
|
'gamma.flag': boolSlice('cap-gamma', true),
|
|
};
|
|
|
|
const result = mergeFederatedConfig({
|
|
configSchema: registryAfterAlphaRemoved,
|
|
isCentralKey: neverCentral,
|
|
userConfig: {},
|
|
});
|
|
|
|
// alpha.flag not present (removed)
|
|
assert.ok(
|
|
!result.validKeys.includes('alpha.flag'),
|
|
'alpha.flag must not appear after cap-alpha removal',
|
|
);
|
|
|
|
// alphabeta.flag MUST be present (distinct capability)
|
|
assert.ok(
|
|
result.validKeys.includes('alphabeta.flag'),
|
|
'alphabeta.flag must survive removal of alpha capability',
|
|
);
|
|
assert.strictEqual(
|
|
result.values['alphabeta.flag'],
|
|
false,
|
|
'alphabeta.flag value must be false (its own default)',
|
|
);
|
|
|
|
// gamma.flag also unaffected
|
|
assert.ok(
|
|
result.validKeys.includes('gamma.flag'),
|
|
'gamma.flag must be unaffected by alpha removal',
|
|
);
|
|
assert.strictEqual(
|
|
result.values['gamma.flag'],
|
|
true,
|
|
'gamma.flag value must be true (its own default)',
|
|
);
|
|
});
|
|
});
|