From db8456631733ee2c37a71287ea217186d0414907 Mon Sep 17 00:00:00 2001 From: Akshat Shukla Date: Sun, 11 Jan 2026 11:13:42 +0530 Subject: [PATCH 1/4] feat: add plugin dependency support (Issue #657) --- .../src/__tests__/runner.test.ts | 43 ++++++ packages/plugin-runner/src/config.ts | 2 + packages/plugin-runner/src/runner.ts | 133 +++++++++++++++++- 3 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 packages/plugin-runner/src/__tests__/runner.test.ts diff --git a/packages/plugin-runner/src/__tests__/runner.test.ts b/packages/plugin-runner/src/__tests__/runner.test.ts new file mode 100644 index 00000000..c21360fd --- /dev/null +++ b/packages/plugin-runner/src/__tests__/runner.test.ts @@ -0,0 +1,43 @@ +/** + * Dependency resolution tests + */ + +import { describe, it, expect } from "vitest"; +import { resolvePluginOrder } from "../runner"; + +describe("resolvePluginOrder", () => { + it("orders a simple chain", () => { + const order = resolvePluginOrder([ + ["A", { depends_on: [] }], + ["B", { depends_on: ["A"] }], + ]); + + expect(order).toEqual(["A", "B"]); + }); + + it("respects dependencies even if config order is reversed", () => { + const order = resolvePluginOrder([ + ["B", { depends_on: ["A"] }], + ["A", { depends_on: [] }], + ]); + + expect(order).toEqual(["A", "B"]); + }); + + it("fails for missing dependency IDs", () => { + expect(() => + resolvePluginOrder([ + ["A", { depends_on: ["X"] }], + ]) + ).toThrow('Plugin "A" depends on unknown plugin "X"'); + }); + + it("fails for cycles with a readable path", () => { + expect(() => + resolvePluginOrder([ + ["A", { depends_on: ["B"] }], + ["B", { depends_on: ["A"] }], + ]) + ).toThrow("Circular dependency detected: A -> B -> A"); + }); +}); diff --git a/packages/plugin-runner/src/config.ts b/packages/plugin-runner/src/config.ts index 960cb80d..2cab7fb0 100644 --- a/packages/plugin-runner/src/config.ts +++ b/packages/plugin-runner/src/config.ts @@ -14,6 +14,8 @@ const PluginConfigSchema = z.object({ name: z.string().optional(), source: z.string(), // Can be URL, file://, or package name like @leaderboard/plugin-dummy config: z.record(z.string(), z.unknown()).optional(), + // Plugin IDs here refer to the keys in the plugins map (not package names) + depends_on: z.array(z.string()).optional(), }); /** diff --git a/packages/plugin-runner/src/runner.ts b/packages/plugin-runner/src/runner.ts index 92a6a065..b446a779 100644 --- a/packages/plugin-runner/src/runner.ts +++ b/packages/plugin-runner/src/runner.ts @@ -29,21 +29,32 @@ export async function runPlugins( logger.info(`Running ${pluginEntries.length} plugins`); + const pluginOrder = resolvePluginOrder(pluginEntries); + logger.info(`Resolved plugin order: ${pluginOrder.join(", ")}`); + // Load all plugins const loadedPlugins: Array<{ id: string; plugin: Plugin; config: Record; }> = []; + const loadedById = new Map; + }>(); - for (const [pluginId, pluginConfig] of pluginEntries) { + for (const pluginId of pluginOrder) { + const pluginConfig = plugins[pluginId]; try { const plugin = await loadPlugin(pluginConfig.source, logger); - loadedPlugins.push({ + const entry = { id: pluginId, plugin, config: (pluginConfig.config || {}) as Record, - }); + }; + loadedPlugins.push(entry); + loadedById.set(pluginId, entry); } catch (error) { logger.error(`Failed to load plugin ${pluginId}`, error as Error); throw error; @@ -52,7 +63,12 @@ export async function runPlugins( // Run setup phase for all plugins logger.info("Running setup phase for all plugins"); - for (const { id, plugin, config: pluginConfig } of loadedPlugins) { + for (const pluginId of pluginOrder) { + const entry = loadedById.get(pluginId); + if (!entry) { + throw new Error(`Loaded plugin entry missing for ${pluginId}`); + } + const { id, plugin, config: pluginConfig } = entry; if (plugin.setup) { try { logger.info(`Running setup for plugin: ${plugin.name}`); @@ -75,7 +91,12 @@ export async function runPlugins( // Run scrape phase for all plugins logger.info("Running scrape phase for all plugins"); - for (const { id, plugin, config: pluginConfig } of loadedPlugins) { + for (const pluginId of pluginOrder) { + const entry = loadedById.get(pluginId); + if (!entry) { + throw new Error(`Loaded plugin entry missing for ${pluginId}`); + } + const { id, plugin, config: pluginConfig } = entry; try { logger.info(`Running scrape for plugin: ${plugin.name}`); const ctx: PluginContext = { @@ -94,3 +115,105 @@ export async function runPlugins( logger.info("All plugins completed successfully"); } + +export function resolvePluginOrder( + pluginEntries: Array<[string, { depends_on?: unknown }]> +): string[] { + const pluginIds = pluginEntries.map(([id]) => id); + const idSet = new Set(pluginIds); + const deps = new Map(); + const indexById = new Map(); + + pluginIds.forEach((id, idx) => indexById.set(id, idx)); + + for (const [id, pluginConfig] of pluginEntries) { + const rawDeps = Array.isArray(pluginConfig.depends_on) + ? pluginConfig.depends_on + : []; + + const uniqueDeps = Array.from(new Set(rawDeps)); + + for (const depId of uniqueDeps) { + if (!idSet.has(depId)) { + throw new Error( + `Plugin "${id}" depends on unknown plugin "${depId}"` + ); + } + + if (depId === id) { + throw new Error(`Plugin "${id}" cannot depend on itself`); + } + } + + deps.set(id, uniqueDeps); + } + + // Detect cycles with DFS to produce a readable path + const visiting = new Set(); + const visited = new Set(); + const stack: string[] = []; + + const visit = (id: string) => { + if (visited.has(id)) return; + + if (visiting.has(id)) { + const cycleStart = stack.indexOf(id); + const cyclePath = [...stack.slice(cycleStart), id].join(" -> "); + throw new Error(`Circular dependency detected: ${cyclePath}`); + } + + visiting.add(id); + stack.push(id); + + for (const depId of deps.get(id) || []) { + visit(depId); + } + + stack.pop(); + visiting.delete(id); + visited.add(id); + }; + + for (const id of pluginIds) { + visit(id); + } + + // Kahn's algorithm with stable, insertion-order tie-breaking + const indegree = new Map(); + for (const id of pluginIds) { + indegree.set(id, 0); + } + for (const [id, depList] of deps.entries()) { + for (const depId of depList) { + indegree.set(id, (indegree.get(id) || 0) + 1); + } + } + + const queue: string[] = pluginIds.filter((id) => (indegree.get(id) || 0) === 0); + queue.sort((a, b) => (indexById.get(a) || 0) - (indexById.get(b) || 0)); + + const order: string[] = []; + + while (queue.length > 0) { + const next = queue.shift() as string; + order.push(next); + + for (const [id, depList] of deps.entries()) { + if (depList.includes(next)) { + const current = (indegree.get(id) || 0) - 1; + indegree.set(id, current); + if (current === 0) { + queue.push(id); + } + } + } + + queue.sort((a, b) => (indexById.get(a) || 0) - (indexById.get(b) || 0)); + } + + if (order.length !== pluginIds.length) { + throw new Error("Circular dependency detected while ordering plugins"); + } + + return order; +} From 4b91571cb31aa397373b8873f587c42fd0364577 Mon Sep 17 00:00:00 2001 From: Akshat Shukla Date: Sun, 11 Jan 2026 11:32:33 +0530 Subject: [PATCH 2/4] Update packages/plugin-runner/src/runner.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/plugin-runner/src/runner.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/plugin-runner/src/runner.ts b/packages/plugin-runner/src/runner.ts index b446a779..5b5b8d1b 100644 --- a/packages/plugin-runner/src/runner.ts +++ b/packages/plugin-runner/src/runner.ts @@ -116,6 +116,28 @@ export async function runPlugins( logger.info("All plugins completed successfully"); } +/** + * Resolve a valid execution order for plugins based on their declared dependencies. + * + * Each entry in {@link pluginEntries} is a tuple of the plugin identifier and its + * configuration object. The configuration may declare a `depends_on` property, + * which, when present and an array, lists the identifiers of other plugins that + * must run before the current plugin. + * + * The function validates that all declared dependencies refer to known plugins, + * that no plugin depends on itself, and that there are no circular dependency + * chains. If validation passes, it returns the list of plugin identifiers in an + * order that satisfies all dependency constraints. + * + * @param pluginEntries - Array of `[pluginId, pluginConfig]` tuples where + * `pluginConfig.depends_on` (if present) is expected to be an array of plugin + * identifiers that the plugin depends on. + * @returns An array of plugin identifiers ordered so that each plugin appears + * after all of the plugins it depends on. + * @throws {Error} If a plugin declares a dependency on an unknown plugin. + * @throws {Error} If a plugin declares a dependency on itself. + * @throws {Error} If a circular dependency between plugins is detected. + */ export function resolvePluginOrder( pluginEntries: Array<[string, { depends_on?: unknown }]> ): string[] { From 665b864906ee8dba1b42dbcd2701c581761887cf Mon Sep 17 00:00:00 2001 From: Akshat Shukla Date: Sun, 11 Jan 2026 11:34:21 +0530 Subject: [PATCH 3/4] Update packages/plugin-runner/src/runner.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/plugin-runner/src/runner.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin-runner/src/runner.ts b/packages/plugin-runner/src/runner.ts index 5b5b8d1b..b8a16ae0 100644 --- a/packages/plugin-runner/src/runner.ts +++ b/packages/plugin-runner/src/runner.ts @@ -207,7 +207,7 @@ export function resolvePluginOrder( } for (const [id, depList] of deps.entries()) { for (const depId of depList) { - indegree.set(id, (indegree.get(id) || 0) + 1); + indegree.set(depId, (indegree.get(depId) || 0) + 1); } } From d6254c60ce877ca2f75b7661642f8431db62bc05 Mon Sep 17 00:00:00 2001 From: Akshat Shukla Date: Sun, 11 Jan 2026 11:34:42 +0530 Subject: [PATCH 4/4] Update packages/plugin-runner/src/runner.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/plugin-runner/src/runner.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/plugin-runner/src/runner.ts b/packages/plugin-runner/src/runner.ts index b8a16ae0..8ca0fff1 100644 --- a/packages/plugin-runner/src/runner.ts +++ b/packages/plugin-runner/src/runner.ts @@ -206,9 +206,7 @@ export function resolvePluginOrder( indegree.set(id, 0); } for (const [id, depList] of deps.entries()) { - for (const depId of depList) { - indegree.set(depId, (indegree.get(depId) || 0) + 1); - } + indegree.set(id, (indegree.get(id) || 0) + depList.length); } const queue: string[] = pluginIds.filter((id) => (indegree.get(id) || 0) === 0);