Files
msd-core/tests/registry-axes-parity.test.cjs
Tom Boucher 9624167eec fix(#2810): accept the documented effortSurface axis on EoS registry entries (#2813)
* fix(#2810): accept the documented effortSurface axis on EoS registry entries

The EoS registry schema required an exact eight-key `interactions.axes`
object, while `docs/registries/README.md` and `CONTEXT.md` both documented
nine keys including `effortSurface`. An entry that faithfully mirrored its
upstream descriptor's `effortSurface` key was rejected outright.

`effortSurface` reached the runtime-descriptor vocabulary through ADR-1239
amendment #2481 (`HOST_INTEGRATION_AXES`), but the registry's hand-maintained
copy of that vocabulary never picked it up. The runtime-descriptor surface is
guarded by tests/host-integration-validator-parity.test.cjs; the registry copy
had no equivalent guard, which is what let the two drift.

Accept `effortSurface` as an OPTIONAL ninth axis validated against the
canonical ['argv','none'] rather than a required one: registry entries mirror
their upstream registry/eos-entry.json byte-for-byte, so requiring it would
retroactively invalidate every entry published before the amendment.

Adds tests/registry-axes-parity.test.cjs, which asserts that every key shared
between the registry vocabulary and HOST_INTEGRATION_AXES has an identical
enum array, plus limit-1/limit/limit+1 boundary coverage on the axes key set.

Closes #2810

* test(#2810): fail when a canonical axis is added but never mirrored

The enum-equality assertion compares only keys the registry and
HOST_INTEGRATION_AXES already share, so it is blind to the exact drift that
produced #2810: a new canonical axis appears and the registry copy is never
told. Verified by simulation — mutating an enum is caught, adding a new
canonical key is not.

Assert instead that every HOST_INTEGRATION_AXES key is either modeled by the
registry or named in an explicit NOT_MODELLED allowlist (subagentToolkit and
isolation, both dispatch sub-fields the registry collapses into its free-form
dispatch summary). Adding a canonical axis now fails until someone decides
which bucket it belongs in. The allowlist is itself guarded against going
stale.

Refs #2810

* fix(#2810): harden the axis value lookup with the CodeQL barrier pattern

Both orthogonal reviews flagged the same line: `AXES[key] !== undefined`
is not an own-property test, and the bracket reads are shaped like a
prototype-pollution sink even though the unknown-key gate above provably
makes them unreachable.

Switch the presence test to `Object.hasOwn` and add the repo's inline
literal guards (`capability-state.cts:146-155`, "Prototype-pollution guard
(inline literal, CodeQL barrier)"), which CodeQL can follow where it cannot
follow the `.includes()` filter that actually does the work.

Behavior is unchanged — re-verified all five axes key-count shapes plus a
genuine own `__proto__` property built through JSON.parse (the shape a
third-party registry PR would submit): it is rejected as an unknown key and
Object.prototype is untouched.

Refs #2810

* chore(#2810): backfill changeset PR number
2026-07-29 07:00:35 -04:00

222 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* tests/registry-axes-parity.test.cjs — regression coverage for issue #2810.
*
* `scripts/registry-schema.cjs`'s `AXES` (the eight ADR-1239 negotiated axes
* mirrored into the EoS Registry schema) and `src/host-integration.cts`'s
* `HOST_INTEGRATION_AXES` (the canonical runtime vocabulary, compiled to
* `gsd-core/bin/lib/host-integration.cjs`) are two independent, hand-written
* mirrors of the same underlying axis vocabulary. ADR-1239 amendment #2481
* added a ninth axis, `effortSurface`, to the canonical vocabulary but not to
* the registry's closed `AXES` key set — so a registry entry that faithfully
* mirrored its upstream `registry/eos-entry.json` (which DOES carry
* `effortSurface`) was rejected outright by the registry's exact-key-set
* check. `OPTIONAL_AXES` fixes that by adding `effortSurface` as a ninth,
* OPTIONAL registry axis key. This file asserts the two vocabularies stay in
* parity going forward, and pins the boundary behavior of the fix itself.
*
* Entries below are hand-built plain objects — never read from
* `docs/registries/eos.json` on disk, and no source file is read+string
* matched (both would trip `local/no-source-grep` / defeat the point of a
* behavioral regression test).
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const {
AXES,
OPTIONAL_AXES,
AXES_FREE_STRING,
validateEntries,
renderMarkdown,
} = require(path.join(__dirname, '..', 'scripts', 'registry-schema.cjs'));
const { HOST_INTEGRATION_AXES } = require(
path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'host-integration.cjs'),
);
// ─── Fixtures ─────────────────────────────────────────────────────────────
// A fully-valid eos entry carrying exactly the eight REQUIRED axes keys (no
// `effortSurface`) — same shape as the two real docs/registries/eos.json
// entries (gsd-cursor, gsd-omp), both published before ADR-1239 amendment
// #2481 and neither carrying `effortSurface`.
function baseEosEntry() {
return {
id: 'my-host-plugin',
name: 'My Host Plugin',
type: 'eos',
repo: 'octocat/my-host-plugin',
description: 'Embeds GSD as an orchestration engine in My Host.',
author: 'Octocat',
license: 'MIT',
enginesGsd: '>=1.6.0 <3.0.0',
install: 'See the My Host plugin marketplace listing.',
uninstall: 'Uninstall via the My Host plugin manager.',
protocolVersion: 1,
interactions: {
interfacePoints: ['command', 'state'],
profile: 'programmatic-cli',
axes: {
embeddingMode: 'imperative',
commandSurface: 'slash-file',
dispatch: 'Supports nested background dispatch up to depth 3.',
modelMode: 'active',
hookBus: 'host',
stateIO: 'filesystem',
transport: 'mcp',
runtime: 'node',
},
},
discussion: 'https://github.com/octocat/my-host-plugin/discussions/2',
};
}
// ─── Parity: registry AXES/OPTIONAL_AXES vs canonical HOST_INTEGRATION_AXES ──
describe('registry-axes-parity: AXES/OPTIONAL_AXES vs HOST_INTEGRATION_AXES', () => {
test('every key shared between (AXES ∪ OPTIONAL_AXES) and HOST_INTEGRATION_AXES has an identical enum array', () => {
const registryAxisKeys = new Set([...Object.keys(AXES), ...Object.keys(OPTIONAL_AXES)]);
const canonicalAxisKeys = new Set(Object.keys(HOST_INTEGRATION_AXES));
const shared = [...registryAxisKeys].filter((k) => canonicalAxisKeys.has(k));
// Sanity: the shared set must be non-empty, or the loop below would pass vacuously.
assert.ok(shared.length > 0, 'expected at least one axis key shared between the two vocabularies');
for (const key of shared) {
const registryValue = AXES[key] !== undefined ? AXES[key] : OPTIONAL_AXES[key];
if (registryValue === AXES_FREE_STRING) continue; // dispatch: asserted separately below
assert.deepEqual(
registryValue,
HOST_INTEGRATION_AXES[key],
`registry axis "${key}" enum must match HOST_INTEGRATION_AXES.${key}`,
);
}
});
test('dispatch is registry-only — absent from HOST_INTEGRATION_AXES — and carries the free-string sentinel', () => {
assert.equal(AXES.dispatch, AXES_FREE_STRING);
assert.equal(Array.isArray(AXES.dispatch), false);
assert.equal(Object.hasOwn(HOST_INTEGRATION_AXES, 'dispatch'), false);
});
test('OPTIONAL_AXES.effortSurface equals the canonical HOST_INTEGRATION_AXES.effortSurface exactly', () => {
assert.deepEqual(OPTIONAL_AXES.effortSurface, ['argv', 'none']);
assert.deepEqual(OPTIONAL_AXES.effortSurface, HOST_INTEGRATION_AXES.effortSurface);
});
// The enum-equality test above compares only keys the two vocabularies ALREADY
// share, so it is blind to the drift mode that actually produced #2810: a new
// canonical axis appears and the registry copy is never told. This test closes
// that hole — every canonical axis must be either modeled here or explicitly
// declared out of scope, so adding a canonical axis fails until someone
// decides which it is.
test('every HOST_INTEGRATION_AXES key is either modeled by the registry or explicitly declared out of scope', () => {
// Deliberately not modeled: both are `dispatch` sub-fields hoisted into the
// flat canonical map (CONTEXT.md's Host-Integration Interface entry lists
// dispatch as `{…, subagentToolkit, isolation}`). This registry collapses all
// of dispatch into one free-form human summary string, so they are covered by
// `AXES.dispatch` rather than carried as separate axes.
const NOT_MODELLED = Object.freeze(['subagentToolkit', 'isolation']);
const modelled = new Set([...Object.keys(AXES), ...Object.keys(OPTIONAL_AXES)]);
const unaccounted = Object.keys(HOST_INTEGRATION_AXES).filter(
(key) => !modelled.has(key) && !NOT_MODELLED.includes(key),
);
assert.deepEqual(
unaccounted,
[],
`HOST_INTEGRATION_AXES gained axis key(s) the EoS registry neither models nor excludes: ${unaccounted.join(', ')}. ` +
'Add each to AXES (required) or OPTIONAL_AXES (optional) in scripts/registry-schema.cjs, ' +
'or list it in this test\'s NOT_MODELLED allowlist with the reason it is out of scope.',
);
// Guard the allowlist itself: an entry that no longer exists canonically is
// stale and would silently widen the exemption for a future same-named axis.
for (const key of NOT_MODELLED) {
assert.ok(
Object.hasOwn(HOST_INTEGRATION_AXES, key),
`NOT_MODELLED lists "${key}", which is no longer a HOST_INTEGRATION_AXES key — remove it`,
);
}
});
});
// ─── Boundary coverage: axes key-count limit-1 / limit / limit+1 ──────────
describe('validateEntries: eos axes key-count boundaries (limit-1 / limit / limit+1)', () => {
test('limit-1: 7 keys (one required key missing) is INVALID and names the missing key', () => {
const entry = baseEosEntry();
delete entry.interactions.axes.runtime;
assert.equal(Object.keys(entry.interactions.axes).length, 7);
const verdict = validateEntries([entry], { type: 'eos' });
assert.equal(verdict.ok, false);
const err = verdict.errors.find((e) => e.field === 'interactions.axes' && /missing/i.test(e.reason));
assert.ok(err, `expected a missing-key error, got: ${JSON.stringify(verdict.errors)}`);
assert.ok(err.reason.includes('runtime'), `expected the error to name "runtime", got: ${err.reason}`);
});
test('limit: 8 keys (exactly the required axes) is VALID', () => {
const entry = baseEosEntry();
assert.equal(Object.keys(entry.interactions.axes).length, 8);
const verdict = validateEntries([entry], { type: 'eos' });
assert.equal(verdict.ok, true);
assert.deepEqual(verdict.errors, []);
});
test('limit+1: 8 required + effortSurface = 9 keys is VALID', () => {
const entry = baseEosEntry();
entry.interactions.axes.effortSurface = 'argv';
assert.equal(Object.keys(entry.interactions.axes).length, 9);
const verdict = validateEntries([entry], { type: 'eos' });
assert.equal(verdict.ok, true);
assert.deepEqual(verdict.errors, []);
});
test('8 required + an unknown 9th key (bogusAxis) is INVALID and names the unknown key', () => {
const entry = baseEosEntry();
entry.interactions.axes.bogusAxis = 'x';
assert.equal(Object.keys(entry.interactions.axes).length, 9);
const verdict = validateEntries([entry], { type: 'eos' });
assert.equal(verdict.ok, false);
const err = verdict.errors.find((e) => e.field === 'interactions.axes' && /unknown/i.test(e.reason));
assert.ok(err, `expected an unknown-key error, got: ${JSON.stringify(verdict.errors)}`);
assert.ok(err.reason.includes('bogusAxis'), `expected the error to name "bogusAxis", got: ${err.reason}`);
});
test('8 required + effortSurface with a value outside the enum ("config-file") is INVALID', () => {
const entry = baseEosEntry();
entry.interactions.axes.effortSurface = 'config-file';
const verdict = validateEntries([entry], { type: 'eos' });
assert.equal(verdict.ok, false);
assert.ok(verdict.errors.some((e) => e.field === 'interactions.axes.effortSurface'));
});
});
// ─── renderMarkdown: effortSurface presence/absence ────────────────────────
describe('renderMarkdown: eos effortSurface rendering', () => {
test('an entry carrying effortSurface renders "effortSurface=argv"', () => {
const entry = baseEosEntry();
entry.interactions.axes.effortSurface = 'argv';
const rendered = renderMarkdown([entry], { type: 'eos', sourceFile: 'eos.json' });
assert.ok(rendered.includes('effortSurface=argv'), rendered);
});
test('an entry that omits effortSurface renders no "effortSurface" substring at all', () => {
const entry = baseEosEntry();
const rendered = renderMarkdown([entry], { type: 'eos', sourceFile: 'eos.json' });
assert.ok(!rendered.includes('effortSurface'), rendered);
});
});