From 8b1f0e2d90c03fc5de6077f868af1548485cc466 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 16 Apr 2026 12:55:40 -0400 Subject: [PATCH] core: add documentation comments to plugin configuration merge logic Adds explanatory comments to config.ts and plugin.ts clarifying: - How plugin specs are stored and normalized during config loading - Why plugin_origins tracks provenance for location-sensitive decisions - Why path-like specs are resolved early to prevent reinterpretation during merges - How plugin deduplication works while keeping origin metadata for writes and diagnostics --- packages/opencode/src/config/config.ts | 23 ++++++++++++++++++----- packages/opencode/src/config/plugin.ts | 11 ++++++++++- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7df5dbe2ff..ed3be88082 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -93,6 +93,7 @@ export const Info = z .describe( "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.", ), + // User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged. plugin: ConfigPlugin.Spec.array().optional(), share: z .enum(["manual", "auto", "disabled"]) @@ -267,6 +268,8 @@ export const Info = z }) export type Info = z.output & { + // plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together + // with the file and scope it came from so later runtime code can make location-sensitive decisions. plugin_origins?: ConfigPlugin.Origin[] } @@ -420,6 +423,8 @@ export const layer = Layer.effect( if (data.plugin && isFile) { const list = data.plugin for (let i = 0; i < list.length; i++) { + // Normalize path-like plugin specs while we still know which config file declared them. + // This prevents `./plugin.ts` from being reinterpreted relative to some later merge location. list[i] = yield* Effect.promise(() => ConfigPlugin.resolvePluginSpec(list[i], options.path)) } } @@ -505,20 +510,26 @@ export const layer = Layer.effect( const consoleManagedProviders = new Set() let activeOrgName: string | undefined - const scope = Effect.fnUntraced(function* (source: string) { + const pluginScopeForSource = Effect.fnUntraced(function* (source: string) { if (source.startsWith("http://") || source.startsWith("https://")) return "global" if (source === "OPENCODE_CONFIG_CONTENT") return "local" if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local" return "global" }) - const track = Effect.fnUntraced(function* ( + const mergePluginOrigins = Effect.fnUntraced(function* ( source: string, + // mergePluginOrigins receives raw Specs from one config source, before provenance for this merge step + // is attached. list: ConfigPlugin.Spec[] | undefined, + // Scope can be inferred from the source path, but some callers already know whether the config should + // behave as global or local and can pass that explicitly. kind?: ConfigPlugin.Scope, ) { if (!list?.length) return - const hit = kind ?? (yield* scope(source)) + const hit = kind ?? (yield* pluginScopeForSource(source)) + // Merge newly seen plugin origins with previously collected ones, then dedupe by plugin identity while + // keeping the winning source/scope metadata for downstream installs, writes, and diagnostics. const plugins = ConfigPlugin.deduplicatePluginOrigins([ ...(result.plugin_origins ?? []), ...list.map((spec) => ({ spec, source, scope: hit })), @@ -529,7 +540,7 @@ export const layer = Layer.effect( const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => { result = mergeConfigConcatArrays(result, next) - return track(source, next.plugin, kind) + return mergePluginOrigins(source, next.plugin, kind) } for (const [key, value] of Object.entries(auth)) { @@ -617,8 +628,10 @@ export const layer = Layer.effect( result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir))) result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir))) result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir))) + // Auto-discovered plugins under `.opencode/plugin(s)` are already local files, so ConfigPlugin.load + // returns normalized Specs and we only need to attach origin metadata here. const list = yield* Effect.promise(() => ConfigPlugin.load(dir)) - yield* track(dir, list) + yield* mergePluginOrigins(dir, list) } if (process.env.OPENCODE_CONFIG_CONTENT) { diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts index d13a9d5adc..3a10c0a715 100644 --- a/packages/opencode/src/config/plugin.ts +++ b/packages/opencode/src/config/plugin.ts @@ -8,11 +8,16 @@ export namespace ConfigPlugin { const Options = z.record(z.string(), z.unknown()) export type Options = z.infer + // Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options. + // It answers "what should we load?" but says nothing about where that value came from. export const Spec = z.union([z.string(), z.tuple([z.string(), Options])]) export type Spec = z.infer export type Scope = "global" | "local" + // Origin keeps the original config provenance attached to a spec. + // After multiple config files are merged, callers still need to know which file declared the plugin + // and whether it should behave like a global or project-local plugin. export type Origin = { spec: Spec source: string @@ -33,7 +38,7 @@ export namespace ConfigPlugin { return plugins } - export function pluginSpecifier(plugin: ConfigPlugin.Spec): string { + export function pluginSpecifier(plugin: Spec): string { return Array.isArray(plugin) ? plugin[0] : plugin } @@ -41,6 +46,8 @@ export namespace ConfigPlugin { return Array.isArray(plugin) ? plugin[1] : undefined } + // Path-like specs are resolved relative to the config file that declared them so merges later on do not + // accidentally reinterpret `./plugin.ts` relative to some other directory. export async function resolvePluginSpec(plugin: Spec, configFilepath: string): Promise { const spec = pluginSpecifier(plugin) if (!isPathPluginSpec(spec)) return plugin @@ -58,6 +65,8 @@ export namespace ConfigPlugin { return resolved } + // Dedupe on the load identity (package name for npm specs, exact file URL for local specs), but keep the + // full Origin so downstream code still knows which config file won and where follow-up writes should go. export function deduplicatePluginOrigins(plugins: Origin[]): Origin[] { const seen = new Set() const list: Origin[] = []