diff --git a/.changeset/merry-lemurs-tumble.md b/.changeset/merry-lemurs-tumble.md new file mode 100644 index 000000000..f26238478 --- /dev/null +++ b/.changeset/merry-lemurs-tumble.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2813 +--- +**EoS Registry entries carrying the documented `effortSurface` axis are no longer rejected** — the registry validator required an exact eight-key axes object, so an entry that faithfully mirrored its upstream descriptor's optional ninth `effortSurface` key (`argv` or `none`, added by ADR-1239 amendment #2481) failed validation outright. (#2810) diff --git a/.github/PULL_REQUEST_TEMPLATE/registry-entry.md b/.github/PULL_REQUEST_TEMPLATE/registry-entry.md index c5da25e72..6a6b4afb0 100644 --- a/.github/PULL_REQUEST_TEMPLATE/registry-entry.md +++ b/.github/PULL_REQUEST_TEMPLATE/registry-entry.md @@ -43,7 +43,7 @@ Full schema and process: [docs/registries/README.md](../../docs/registries/READM - [ ] `id`, `name`, `type`, `repo`, `description`, `author`, `license`, `enginesGsd`, `install`, `uninstall`, `interactions`, `discussion` are all present and non-empty - [ ] **(Capability entries only)** `interactions.loopExtensionPoints` is a non-empty subset of the 12 Loop Extension Points, `interactions.hookKinds` ⊆ `{step, contribution, gate}`, and `interactions.configKeys` / `requires` / `runtimeCompat` / `produces` / `consumes` are present (empty arrays are fine where nothing applies) -- [ ] **(EoS entries only)** `protocolVersion` is an integer ≥ 1, `interactions.interfacePoints` is a non-empty subset of the six interface points, `interactions.profile` is one of `programmatic-cli` / `declarative-cli` / `ide`, and `interactions.axes` has exactly the eight required axis keys +- [ ] **(EoS entries only)** `protocolVersion` is an integer ≥ 1, `interactions.interfacePoints` is a non-empty subset of the six interface points, `interactions.profile` is one of `programmatic-cli` / `declarative-cli` / `ide`, and `interactions.axes` has exactly the eight required axis keys plus, optionally, `effortSurface` (`argv` / `none`) ## Ownership & non-endorsement diff --git a/CONTEXT.md b/CONTEXT.md index 4cceba95c..a93ff4528 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -221,7 +221,7 @@ Runtime seam (`gsd-core/bin/lib/capability-loader.cjs`, ADR-1244 D2) that compos Human-facing discoverability catalog (`docs/registries/capability-registry.md`, generated from `docs/registries/capabilities.json`; issue #2182) listing third-party Feature Capabilities registered by a docs PR so a solo developer can find one before installing it. Distinct from **Capability Registry** (the generated runtime manifest compiled from first-party `capability.json` declarations, ADR-894) and **Capability Registry Overlay** (the runtime seam that merges an installed third-party manifest into that generated registry at load time, ADR-1244 D2): this registry is a static document rendered by `scripts/gen-registry.cjs`, not a runtime data structure or loader. Each entry enumerates the capability's Loop Extension Points and hook kinds so a reader can judge blast radius before running `gsd capability install`, and declares its `engines.gsd` range. Inclusion is an explicit non-endorsement — a maintainer merged a link, nothing more — per `docs/registries/README.md`. ### EoS Registry -Human-facing discoverability catalog (`docs/registries/eos-registry.md`, generated from `docs/registries/eos.json`; issue #2182) listing third-party Embeddable Orchestration System (EoS) host integrations — projects that embed GSD as an orchestration engine behind the ADR-1239 six-interface-point Host-Integration Interface. Entries are registered by the same docs-PR process, schema conventions, and non-endorsement stance as the **Community Capability Registry**, but enumerate the six interface points, the nine negotiated axes, and `protocolVersion` in place of Loop Extension Points and hook kinds. It has no generated-manifest or Capability Registry Overlay counterpart: an ADR-1239 host integration runs inside the third-party host, not inside GSD's own capability loader, so there is nothing for a runtime registry to merge. See `docs/registries/README.md` for the full entry schema. +Human-facing discoverability catalog (`docs/registries/eos-registry.md`, generated from `docs/registries/eos.json`; issue #2182) listing third-party Embeddable Orchestration System (EoS) host integrations — projects that embed GSD as an orchestration engine behind the ADR-1239 six-interface-point Host-Integration Interface. Entries are registered by the same docs-PR process, schema conventions, and non-endorsement stance as the **Community Capability Registry**, but enumerate the six interface points, the eight negotiated axes plus an optional ninth (`effortSurface`), and `protocolVersion` in place of Loop Extension Points and hook kinds. It has no generated-manifest or Capability Registry Overlay counterpart: an ADR-1239 host integration runs inside the third-party host, not inside GSD's own capability loader, so there is nothing for a runtime registry to merge. See `docs/registries/README.md` for the full entry schema. ### Capability Validator Shared conformance validator (`gsd-core/bin/lib/capability-validator.cjs`, ADR-1244 D2) extracted from `scripts/gen-capability-registry.cjs` so the build-time generator and the runtime overlay loader share one validator implementation. Exports the same `validateCapability(manifest)` surface consumed by both the generator (build-time) and `capability-loader.cjs` (runtime). Generative-parity is CI-guarded: a drift between the generator's validation logic and the extracted module is a hard failure. Callers that previously inlined validation against the generator's internal helpers are migrated to import this module directly. Source of truth: `gsd-core/bin/lib/capability-validator.cjs`. diff --git a/docs/registries/README.md b/docs/registries/README.md index e0a327bd0..8c3dc2133 100644 --- a/docs/registries/README.md +++ b/docs/registries/README.md @@ -118,7 +118,7 @@ Example: |---|---|---| | `interfacePoints` | yes, non-empty | Subset of the six ADR-1239 interface points it binds: `command`, `dispatch`, `model`, `hooks`, `state`, `artifact`. | | `profile` | yes | One of the three host-capability profiles: `programmatic-cli`, `declarative-cli`, `ide`. | -| `axes` | yes | Object with **exactly** the nine ADR-1239 negotiated axes keys: `embeddingMode`, `commandSurface`, `dispatch`, `modelMode`, `hookBus`, `stateIO`, `transport`, `runtime`, `effortSurface`. | +| `axes` | yes | Object carrying **all eight** required ADR-1239 negotiated axes keys — `embeddingMode`, `commandSurface`, `dispatch`, `modelMode`, `hookBus`, `stateIO`, `transport`, `runtime` — and **optionally** a ninth, `effortSurface` (`argv` \| `none`). No other key is accepted. `effortSurface` is optional because ADR-1239 amendment #2481 added it after entries already existed; requiring it would retroactively invalidate every entry published before the amendment. | `axes` value vocabulary: diff --git a/scripts/registry-schema.cjs b/scripts/registry-schema.cjs index 1784c7471..89448eba7 100644 --- a/scripts/registry-schema.cjs +++ b/scripts/registry-schema.cjs @@ -36,6 +36,11 @@ * (`{ namedDispatch, nested, maxDepth, background, subagentToolkit }`) — * this registry accepts a free-form human summary string instead, so it * carries the `AXES_FREE_STRING` sentinel rather than an enum array. + * `OPTIONAL_AXES` adds one further, OPTIONAL key on top of those eight: + * `effortSurface` (ADR-1239 amendment #2481). An entry may omit it + * (every entry published before the amendment stays valid) or declare + * it as `argv` | `none`, mirroring `HOST_INTEGRATION_AXES.effortSurface` + * in `src/host-integration.cts`. * - `CAPABILITY_REQUIRED` / `EOS_REQUIRED` mirror the required top-level * fields for each entry type, including `enginesGsd` (ADR-1244 D1 * "Versioned capability manifest" — the `engines.gsd` semver-range gate, @@ -90,6 +95,18 @@ const AXES = Object.freeze({ runtime: Object.freeze(['node', 'bun', 'sandboxed-web', 'python', 'go', 'rust', 'electron', 'other']), }); +// ─── ADR-1239 amendment #2481 — one additional, OPTIONAL negotiated axis ───── +// `effortSurface` was added to the runtime-descriptor vocabulary AFTER the +// original eight (`HOST_INTEGRATION_AXES.effortSurface` in +// `src/host-integration.cts`). It is kept OPTIONAL here — not folded into +// `AXES` — because registry entries mirror their upstream +// `registry/eos-entry.json` byte-for-byte, and requiring it would +// retroactively invalidate every entry published before the amendment. +// Values must match `HOST_INTEGRATION_AXES.effortSurface` exactly. +const OPTIONAL_AXES = Object.freeze({ + effortSurface: Object.freeze(['argv', 'none']), +}); + // ─── Required top-level fields ─────────────────────────────────────────────── const CAPABILITY_REQUIRED = Object.freeze([ 'id', @@ -246,15 +263,39 @@ function validateEosInteractions(interactions, addError) { if (typeof axes !== 'object' || axes === null || Array.isArray(axes)) { addError('interactions.axes', 'axes must be an object'); } else { - const expectedKeys = Object.keys(AXES); + const requiredKeys = Object.keys(AXES); + const optionalKeys = Object.keys(OPTIONAL_AXES); const actualKeys = Object.keys(axes); const actualKeySet = new Set(actualKeys); - const keysMatch = expectedKeys.length === actualKeys.length && expectedKeys.every((k) => actualKeySet.has(k)); - if (!keysMatch) { - addError('interactions.axes', 'axes key set must exactly match the eight negotiated axes'); - } else { - for (const key of expectedKeys) { - const allowedValues = AXES[key]; + + // Every AXES key is mandatory; an extra key is tolerated ONLY when it is + // a recognized OPTIONAL_AXES key (currently just `effortSurface`) — any + // other extra key is still rejected as unknown. + const missingRequiredKeys = requiredKeys.filter((k) => !actualKeySet.has(k)); + const unknownKeys = actualKeys.filter((k) => !requiredKeys.includes(k) && !optionalKeys.includes(k)); + + if (missingRequiredKeys.length > 0) { + addError('interactions.axes', `axes is missing required key(s): ${missingRequiredKeys.join(', ')}`); + } + if (unknownKeys.length > 0) { + addError('interactions.axes', `axes has unknown key(s): ${unknownKeys.join(', ')}`); + } + + // Only validate individual values once the key set itself is sound — + // mirrors the original gate (values were never checked against a + // malformed key set either). + if (missingRequiredKeys.length === 0 && unknownKeys.length === 0) { + for (const key of actualKeys) { + // Inline literal guards — CodeQL barrier pattern. Reaching here already + // implies `key` is one of the nine literal axis names (the unknown-key + // gate above rejected everything else), so this is unreachable in + // practice; it is written inline anyway because CodeQL cannot follow + // that gate across the `.includes()` filter and would otherwise flag + // the bracket reads below as prototype-pollution sinks. + if (key === '__proto__') continue; + if (key === 'constructor') continue; + if (key === 'prototype') continue; + const allowedValues = Object.hasOwn(AXES, key) ? AXES[key] : OPTIONAL_AXES[key]; const v = axes[key]; if (allowedValues === AXES_FREE_STRING) { if (typeof v !== 'string' || v.trim() === '') { @@ -496,7 +537,14 @@ function renderMarkdown(entries, opts) { lines.push(`- **Author:** ${mdInline(entry.author)}`); if (isEos) { - const axesSummary = Object.keys(AXES) + // Required AXES keys always render, in their fixed order; an OPTIONAL_AXES + // key (e.g. `effortSurface`) renders ONLY when the entry actually carries + // it — an entry that omits it must render byte-identical to before + // OPTIONAL_AXES existed (no `effortSurface=undefined` noise). + const presentOptionalKeys = Object.keys(OPTIONAL_AXES).filter( + (key) => interactions.axes && Object.hasOwn(interactions.axes, key), + ); + const axesSummary = [...Object.keys(AXES), ...presentOptionalKeys] .map((key) => `${key}=${interactions.axes ? interactions.axes[key] : undefined}`) .join(', '); const summary = @@ -556,6 +604,7 @@ module.exports = { INTERFACE_POINTS, PROFILES, AXES, + OPTIONAL_AXES, AXES_FREE_STRING, CAPABILITY_REQUIRED, EOS_REQUIRED, diff --git a/tests/registry-axes-parity.test.cjs b/tests/registry-axes-parity.test.cjs new file mode 100644 index 000000000..c1546bbab --- /dev/null +++ b/tests/registry-axes-parity.test.cjs @@ -0,0 +1,221 @@ +'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); + }); +});