From 1137a3b97657a211758825ee079dac1485388451 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 10 Sep 2026 18:53:20 -0400 Subject: [PATCH] =?UTF-8?q?fix(plugin):=20restore=20the=20no-../src=20depl?= =?UTF-8?q?oyment=20contract=20=E2=80=94=20lazy=20widget-helper=20import?= =?UTF-8?q?=20(#995)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #799 widget twin statically imported the widget-authoring helper from ../src — the one edge the plugin dir's deployment contract forbids ('never anything from ../src/', the clause calib_chain/regime_priors/rehearsal carry): trimmed deployments ship only the plugin dir, so module init died at load and EVERY amicode_* tool vanished from hub sessions (2026-09-10, silent — the service runner captures engine stderr in memory on healthy boots). The lazy fail-soft form keeps module init whole everywhere; where the helper is absent the one widget tool refuses honestly (the engine builtin carries authoring on those surfaces) and the rest of the pack stays registered. A floor test now scans the plugin dir for static parent-relative imports so the contract is enforced by the suite. --- .../opencode-plugin/amicode_tools.ts | 46 ++++++++++++++++--- .../extension/test/amicode_tools_core.test.ts | 36 ++++++++++++++- 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/packages/extension/opencode-plugin/amicode_tools.ts b/packages/extension/opencode-plugin/amicode_tools.ts index 32efcb01..75acb883 100644 --- a/packages/extension/opencode-plugin/amicode_tools.ts +++ b/packages/extension/opencode-plugin/amicode_tools.ts @@ -167,13 +167,33 @@ import { // schema itself; the twin follows it by construction + pin. import { validateWarrantBounds, boundsRefusal } from "./warrant_bounds"; -// Issue #799 — the widget-authoring twin executes against the SAME service -// helper the core table calls (src/amicode_service/widgets.ts). The plugin -// transport is retired from the runtime config (#700 A3 — this file is the -// behavioral reference the parity test pins), so the ../src import rides the -// vitest graph where the parity test exercises it; the runtime MCP server -// bundles the core's own identical call. -import { authorWidget } from "../src/amicode_service/widgets"; +// Issue #799/#995 — the widget-authoring twin executes against the SAME +// service helper the core table calls (src/amicode_service/widgets.ts). +// DEPLOYMENT CONTRACT (the clause calib_chain.ts / regime_priors.ts / +// rehearsal.ts carry too): NEVER a STATIC ../src import — trimmed +// deployments (the hub service bundle) ship ONLY this plugin dir, and a +// static ../src import killed module init there, taking EVERY amicode_* +// tool off hub sessions (2026-09-10, silent: the runner captures engine +// stderr in memory on healthy boots). The lazy form below keeps module +// init whole everywhere; where ../src is absent, the ONE widget tool +// degrades to an honest refusal (the engine's built-in widget tool and +// the MCP transport carry authoring on those surfaces), and the rest of +// the pack stays registered. The vitest graph still resolves the helper +// where it exists (the parity test exercises the full path). +type AuthorWidgetFn = typeof import("../src/amicode_service/widgets")["authorWidget"]; +let authorWidgetCache: AuthorWidgetFn | null | undefined; +async function authorWidgetLazy(): Promise { + if (authorWidgetCache !== undefined) return authorWidgetCache; + try { + const mod = (await import("../src/amicode_service/widgets")) as { + authorWidget: AuthorWidgetFn; + }; + authorWidgetCache = mod.authorWidget; + } catch { + authorWidgetCache = null; // trimmed deployment — refuse honestly, cache the miss + } + return authorWidgetCache; +} // Load line goes to STDERR, not stdout: `opencode debug config` imports plugin // modules before printing the resolved config as JSON on stdout (verified on @@ -530,6 +550,18 @@ returns an error, fix \`js\`/the fields and call it again. description?: string | null; js: string; }) { + // #995: the helper loads lazily (see the contract block above) — on + // trimmed deployments it is absent and THIS tool alone refuses + // honestly; nothing is written. The engine's built-in widget tool + // carries authoring on those surfaces. + const authorWidget = await authorWidgetLazy(); + if (!authorWidget) { + return ( + "Widget tool unavailable on this surface: the widget-authoring service helper " + + "does not ship with this plugin deployment, so nothing was written. Widget " + + "authoring is served by the engine's built-in amicode_author_widget tool here." + ); + } // Same helper, same never-reject discipline: a bad field returns // {ok:false} with a precise error — refuse honestly, write nothing. const r = authorWidget({ diff --git a/packages/extension/test/amicode_tools_core.test.ts b/packages/extension/test/amicode_tools_core.test.ts index 8813f7ab..86c93cb1 100644 --- a/packages/extension/test/amicode_tools_core.test.ts +++ b/packages/extension/test/amicode_tools_core.test.ts @@ -13,7 +13,7 @@ // legacy-migration one-shot, and the migration guard skips when // $AMICODE_PROBLEMS_DIR is set (temp dir → no machine-state writes). import { describe, it, expect } from "vitest"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, readdirSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -292,3 +292,37 @@ describe("the opencode plugin is a thin adapter over the core", () => { expect(viaAdapter).toMatch(/No active problem yet/); }); }); + +// #995 — the plugin dir's DEPLOYMENT CONTRACT, made mechanical. Trimmed +// deployments (the hub service bundle) ship ONLY packages/extension/ +// opencode-plugin/, so a STATIC parent-relative import is an undeployable +// module edge: module init dies at load on those surfaces and EVERY +// amicode_* tool vanishes (the 2026-09-10 hub regression — the widget +// twin's static ../src import, silent because the runner captures engine +// stderr in memory on healthy boots). The contract-legal form for +// reaching outside the dir is a lazy `await import(...)` inside an +// execute, fail-soft with an honest refusal (see authorWidgetLazy). +// The sibling modules calib_chain.ts / regime_priors.ts / rehearsal.ts +// carry the same clause as comments; this floor is the enforcement. +describe("plugin deployment contract (#995)", () => { + const pluginDir = join(__dirname, "..", "opencode-plugin"); + + it("zero static parent-relative imports across the plugin dir", () => { + const files = readdirSync(pluginDir).filter((f) => f.endsWith(".ts")); + // guard: the scan actually scanned a populated dir + expect(files.length).toBeGreaterThan(5); + const offenders: string[] = []; + for (const f of files) { + const lines = readFileSync(join(pluginDir, f), "utf8").split("\n"); + lines.forEach((line, i) => { + if (/^\s*(import|export)\b[^("]*["']\.\.\//.test(line)) { + offenders.push(`${f}:${i + 1}: ${line.trim()}`); + } + }); + } + expect( + offenders, + `static parent-relative imports (use a lazy await import() inside execute instead):\n${offenders.join("\n")}`, + ).toEqual([]); + }); +});