feat(#1680): ADR-1239 Phase C-1 — imperative embedding adapter (composes loadRegistry) [AC2] (#1803)

* feat(#1680): ADR-1239 Phase C-1 — imperative embedding adapter (composes loadRegistry) [AC2]

Phase 3 slice 2 (AC2). The engine-as-library path: createImperativeAdapter
composes loadRegistry({includeInstalled:true}) — first-party-wins + consent +
fail-closed gates, identical trust semantics to the CLI — and binds the engine
surface behind the SAME HostIntegrationInterface the declarative adapter (AC1)
satisfies, plus a registry accessor for the composed capability set.

- src/adapter-imperative.cts: createImperativeAdapter({runtime}, {loadOptions})
  → ImperativeAdapter (kind:'imperative' + .registry + install/uninstall
  delegating to install-engine). Thin: delegates the loop, does not reimplement.
- tests/adapter-imperative.test.cjs: kind (16 runtimes), registry composition
  (loadRegistry called with includeInstalled:true), loadOptions pass-through,
  install/uninstall delegation, fail-closed construction.
- eslint.config.mjs + docs/INVENTORY-MANIFEST.json: ADR-457 ignores entry +
  cli_modules entry for the new emitted .cjs (the two drift gates that bit AC1,
  applied proactively here).

Concrete host binding (OpenCode/VS Code/pi) deferred to Phase 5 (#1682).

* test: remove dead readStateMd helper from bug-1760 test

readStateMd was defined but never called (writeStateMd is the only state-md
helper this test uses). Clears the lone no-unused-vars warning so the repo
lints fully clean (0 problems). No behavior change — test still passes 2/2.

* chore(changeset): add Changed fragment for imperative embedding adapter (#1680)

* fix(adapter-imperative): reword comment to avoid injection-scan substring match

The prompt-injection scan regex 'act\s+as\s+(?:a|an|the)' was matching the
'act as the' substring inside 'contract as the declarative adapter' (contrACT
AS THE). Reword 'contract as' -> 'shape as' — no 'act' substring, identical
meaning. Clears the 'lib source files are clean' + 'codebase prompt injection
scan' security-gate failures.
This commit is contained in:
Tom Boucher
2026-06-28 11:10:59 -04:00
committed by GitHub
parent c642ed0ec5
commit da368311ea
6 changed files with 184 additions and 3 deletions

View File

@@ -0,0 +1,98 @@
'use strict';
/**
* Tests for the imperative embedding adapter (ADR-1239 Phase C-1, AC2 / #1680).
*
* Pins:
* 1. KIND — `kind: 'imperative'`, satisfies the same HostIntegrationInterface
* as the declarative adapter (both bind one engine).
* 2. REGISTRY COMPOSITION — the adapter composes loadRegistry({includeInstalled:
* true}) and exposes the result as `.registry` (first-party ∪ installed, so
* an in-process host gets identical trust semantics to the CLI).
* 3. DELEGATION — install/uninstall delegate in-process to install-engine
* (byte-identity link, same as the declarative adapter).
* 4. FAIL-CLOSED CONSTRUCTION — missing/invalid runtime throws.
*
* Behavioral tests only; delegation + loadRegistry verified via module-ref
* monkeypatch (Node module cache shares the one module object).
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs');
const installEngine = require('../gsd-core/bin/lib/install-engine.cjs');
const capabilityLoader = require('../gsd-core/bin/lib/capability-loader.cjs');
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
const RUNTIMES = Object.keys(registry.runtimes);
test('imperative adapter: kind === "imperative" + runtime echoed + registry present, for all 16 runtimes', () => {
for (const r of RUNTIMES) {
const adapter = createImperativeAdapter({ runtime: r });
assert.strictEqual(adapter.kind, 'imperative', `${r}: kind must be 'imperative'`);
assert.strictEqual(adapter.runtime, r, `${r}: adapter must echo the runtime id`);
assert.ok(adapter.registry && typeof adapter.registry === 'object', `${r}: registry must be present (composed loadRegistry result)`);
assert.strictEqual(typeof adapter.install, 'function', `${r}: install must be a function`);
assert.strictEqual(typeof adapter.uninstall, 'function', `${r}: uninstall must be a function`);
}
});
test('imperative adapter: loadRegistry composed with includeInstalled:true (host gets CLI-equivalent trust semantics)', () => {
// Restore-able spy on capabilityLoader.loadRegistry (module-ref, shared via Node cache).
const original = capabilityLoader.loadRegistry;
const calls = [];
capabilityLoader.loadRegistry = function (opts) {
calls.push(opts);
// Return a minimal stand-in registry so the factory does not crash.
return { _spy: true, runtimes: registry.runtimes };
};
try {
const adapter = createImperativeAdapter({ runtime: 'opencode' });
assert.ok(calls.length >= 1, 'loadRegistry must be invoked once during construction');
assert.strictEqual(calls[0].includeInstalled, true, 'loadRegistry must be called with includeInstalled:true (compose first-party ∪ installed)');
assert.strictEqual(adapter.registry._spy, true, 'adapter.registry must expose the composed loadRegistry result');
} finally {
capabilityLoader.loadRegistry = original;
}
});
test('imperative adapter: loadOptions forwarded to loadRegistry (cwd/gsdHome/hostVersion pass-through)', () => {
const original = capabilityLoader.loadRegistry;
let captured = null;
capabilityLoader.loadRegistry = function (opts) { captured = opts; return { runtimes: registry.runtimes }; };
try {
createImperativeAdapter({ runtime: 'codex' }, { loadOptions: { cwd: '/tmp/proj', hostVersion: '1.7.0' } });
assert.strictEqual(captured.includeInstalled, true, 'includeInstalled default preserved');
assert.strictEqual(captured.cwd, '/tmp/proj', 'loadOptions.cwd forwarded');
assert.strictEqual(captured.hostVersion, '1.7.0', 'loadOptions.hostVersion forwarded');
} finally {
capabilityLoader.loadRegistry = original;
}
});
test('imperative adapter.install/uninstall delegate in-process to install-engine with exact args', () => {
const origInstall = installEngine.installRuntimeArtifacts;
const origUninstall = installEngine.uninstallRuntimeArtifacts;
try {
for (const r of RUNTIMES) {
const adapter = createImperativeAdapter({ runtime: r });
let installArgs = null;
let uninstallArgs = null;
installEngine.installRuntimeArtifacts = function (...a) { installArgs = a; return undefined; };
installEngine.uninstallRuntimeArtifacts = function (...a) { uninstallArgs = a; return undefined; };
adapter.install({ configDir: '/tmp/imp/' + r, scope: 'global', resolvedProfile: { p: 1 } });
adapter.uninstall({ configDir: '/tmp/imp/' + r, scope: 'local' });
assert.deepStrictEqual(installArgs, [r, '/tmp/imp/' + r, 'global', { p: 1 }, undefined], `${r}: install delegation args`);
assert.deepStrictEqual(uninstallArgs, [r, '/tmp/imp/' + r, 'local'], `${r}: uninstall delegation args`);
}
} finally {
installEngine.installRuntimeArtifacts = origInstall;
installEngine.uninstallRuntimeArtifacts = origUninstall;
}
});
test('createImperativeAdapter: throws on missing/invalid runtime (fail-closed construction)', () => {
for (const bad of ['', undefined, null]) {
assert.throws(() => createImperativeAdapter({ runtime: bad }), TypeError, `runtime=${JSON.stringify(bad)} must throw`);
}
assert.throws(() => createImperativeAdapter({}), TypeError, 'missing runtime must throw');
});

View File

@@ -16,9 +16,6 @@ const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
function writeStateMd(tmpDir, content) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
}
function readStateMd(tmpDir) {
return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
}
describe('#1760: state prune engages on template-conformant STATE.md (Phase: X of Y)', () => {
let tmpDir;