FN-5926: break bundled plugin dashboard dependency cycle

Remove direct dashboard package dependencies from bundled plugins while preserving typed dashboard interop.

- add a workspace acyclic dependency regression test for all packages and dashboard-bundled plugins
- expose dashboard app aliases in dashboard TS/Vite/Vitest configs for plugin interop imports
- replace bundled plugin @fusion/dashboard dependencies with local dashboard interop declarations and updated tsconfig path mappings

Files changed:
 .../workspace-dependency-acyclicity.test.ts        | 174 +++++++++++++++++++++
 packages/dashboard/tsconfig.app.json               |   6 +-
 packages/dashboard/tsconfig.test-check.json        |   6 +-
 packages/dashboard/vite.config.ts                  |   4 +
 packages/dashboard/vitest.config.ts                |   4 +
 .../fusion-plugin-cli-printing-press/package.json  |   1 -
 .../src/dashboard-interop.d.ts                     |  19 +++
 .../fusion-plugin-cli-printing-press/tsconfig.json |   7 +-
 .../fusion-plugin-dependency-graph/package.json    |   1 -
 .../src/dashboard-interop.d.ts                     |   6 +
 .../fusion-plugin-dependency-graph/tsconfig.json   |   3 +-
 plugins/fusion-plugin-roadmap/package.json         |   1 -
 .../src/dashboard-interop.d.ts                     |  19 +++
 plugins/fusion-plugin-roadmap/tsconfig.json        |   7 +-
 pnpm-lock.yaml                                     |   9 --
 15 files changed, 248 insertions(+), 19 deletions(-)

Fusion-Task-Id: FN-5926

Fusion-Task-Lineage: 1b39c08a-1415-407d-90cc-b3de89af0316
This commit is contained in:
gsxdsm
2026-06-03 00:12:13 -07:00
parent 0971eced30
commit 7be377da0c
15 changed files with 248 additions and 19 deletions

View File

@@ -0,0 +1,174 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
type PackageManifest = {
name: string;
path: string;
dependencies: Record<string, string>;
devDependencies: Record<string, string>;
optionalDependencies: Record<string, string>;
};
function getRepoRoot(): string {
return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..");
}
function readWorkspacePatterns(repoRoot: string): string[] {
const workspaceConfig = readFileSync(join(repoRoot, "pnpm-workspace.yaml"), "utf8");
return Array.from(workspaceConfig.matchAll(/^\s*-\s*"([^"]+)"\s*$/gm), (match) => match[1]);
}
function expandWorkspacePattern(repoRoot: string, pattern: string): string[] {
if (!pattern.includes("*")) {
return [join(repoRoot, pattern)];
}
const marker = "/*";
if (!pattern.endsWith(marker) || pattern.indexOf("*") !== pattern.length - 1) {
throw new Error(`Unsupported pnpm workspace pattern in test: ${pattern}`);
}
const baseDir = join(repoRoot, pattern.slice(0, -marker.length));
return readdirSync(baseDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => join(baseDir, entry.name));
}
function loadWorkspacePackages(repoRoot: string): PackageManifest[] {
const packageDirs = new Set<string>();
for (const pattern of readWorkspacePatterns(repoRoot)) {
for (const candidate of expandWorkspacePattern(repoRoot, pattern)) {
const manifestPath = join(candidate, "package.json");
if (existsSync(manifestPath)) {
packageDirs.add(candidate);
}
}
}
return Array.from(packageDirs)
.sort()
.map((pkgPath) => {
const manifest = JSON.parse(readFileSync(join(pkgPath, "package.json"), "utf8")) as {
name: string;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
};
return {
name: manifest.name,
path: pkgPath,
dependencies: manifest.dependencies ?? {},
devDependencies: manifest.devDependencies ?? {},
optionalDependencies: manifest.optionalDependencies ?? {},
} satisfies PackageManifest;
});
}
function collectWorkspaceEdges(packages: PackageManifest[]): Map<string, Set<string>> {
const workspaceNames = new Set(packages.map((pkg) => pkg.name));
const graph = new Map<string, Set<string>>();
for (const pkg of packages) {
const edges = new Set<string>();
for (const section of [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies]) {
for (const depName of Object.keys(section)) {
if (workspaceNames.has(depName)) {
edges.add(depName);
}
}
}
graph.set(pkg.name, edges);
}
return graph;
}
function canonicalizeCycle(cycle: string[]): string {
const nodes = cycle.slice(0, -1);
const candidates = nodes.map((_, index) => {
const rotated = [...nodes.slice(index), ...nodes.slice(0, index)];
return [...rotated, rotated[0]].join(" -> ");
});
return candidates.sort()[0] ?? cycle.join(" -> ");
}
function findCycles(graph: Map<string, Set<string>>): string[] {
const state = new Map<string, "visiting" | "done">();
const stack: string[] = [];
const cycles = new Set<string>();
function visit(node: string) {
if (state.get(node) === "done") {
return;
}
if (state.get(node) === "visiting") {
return;
}
state.set(node, "visiting");
stack.push(node);
for (const next of graph.get(node) ?? []) {
if (state.get(next) === "visiting") {
const startIndex = stack.indexOf(next);
const cycle = [...stack.slice(startIndex), next];
cycles.add(canonicalizeCycle(cycle));
continue;
}
visit(next);
}
stack.pop();
state.set(node, "done");
}
for (const node of graph.keys()) {
visit(node);
}
return Array.from(cycles).sort();
}
describe("workspace dependency graph", () => {
it("stays acyclic across all workspace packages", () => {
const packages = loadWorkspacePackages(getRepoRoot());
const graph = collectWorkspaceEdges(packages);
const cycles = findCycles(graph);
expect(
cycles,
cycles.length === 0 ? "expected workspace dependency graph to be acyclic" : `workspace dependency cycles detected:\n${cycles.join("\n")}`,
).toEqual([]);
});
it("prevents dashboard-listed bundled plugins from depending on host packages", () => {
const packages = loadWorkspacePackages(getRepoRoot());
const packageByName = new Map(packages.map((pkg) => [pkg.name, pkg]));
const dashboard = packageByName.get("@fusion/dashboard");
expect(dashboard).toBeDefined();
const bundledPluginNames = Object.keys(dashboard?.dependencies ?? {}).filter((name) => name.startsWith("@fusion-plugin-examples/"));
const hostPackages = ["@fusion/dashboard", "@fusion/engine"];
const offenders = bundledPluginNames.flatMap((pluginName) => {
const plugin = packageByName.get(pluginName);
if (!plugin) {
return [];
}
return hostPackages
.filter((hostName) => Object.prototype.hasOwnProperty.call(plugin.dependencies, hostName))
.map((hostName) => `${pluginName} must not declare ${hostName} in dependencies`);
});
expect(
offenders,
offenders.length === 0
? "expected dashboard-listed bundled plugins to avoid host package runtime dependencies"
: offenders.join("\n"),
).toEqual([]);
});
});

View File

@@ -8,7 +8,11 @@
"noEmit": true,
"types": ["vitest/globals", "@testing-library/jest-dom", "node", "vite/client"],
"paths": {
"node-pty": ["./src/types/node-pty/index.d.ts"]
"node-pty": ["./src/types/node-pty/index.d.ts"],
"@fusion/dashboard/app/components/TaskCard": ["./app/components/TaskCard.tsx"],
"@fusion/dashboard/app/plugins/types": ["./app/plugins/types.ts"],
"@fusion/dashboard/app/utils/projectStorage": ["./app/utils/projectStorage.ts"],
"@fusion/dashboard/app/utils/taskStuck": ["./app/utils/taskStuck.ts"]
}
},
"include": ["app/**/*"],

View File

@@ -9,7 +9,11 @@
"types": ["vitest/globals", "@testing-library/jest-dom", "node", "vite/client"],
"paths": {
"@fusion/test-utils": ["../core/src/__test-utils__/workspace.ts"],
"node-pty": ["./src/types/node-pty/index.d.ts"]
"node-pty": ["./src/types/node-pty/index.d.ts"],
"@fusion/dashboard/app/components/TaskCard": ["./app/components/TaskCard.tsx"],
"@fusion/dashboard/app/plugins/types": ["./app/plugins/types.ts"],
"@fusion/dashboard/app/utils/projectStorage": ["./app/utils/projectStorage.ts"],
"@fusion/dashboard/app/utils/taskStuck": ["./app/utils/taskStuck.ts"]
}
},
"include": ["app/**/*"]

View File

@@ -124,6 +124,10 @@ export default defineConfig({
resolve: {
alias: {
"@fusion/core": resolve(__dirname, "../core/src/types.ts"),
"@fusion/dashboard/app/components/TaskCard": resolve(__dirname, "app/components/TaskCard.tsx"),
"@fusion/dashboard/app/plugins/types": resolve(__dirname, "app/plugins/types.ts"),
"@fusion/dashboard/app/utils/projectStorage": resolve(__dirname, "app/utils/projectStorage.ts"),
"@fusion/dashboard/app/utils/taskStuck": resolve(__dirname, "app/utils/taskStuck.ts"),
"@fusion-plugin-examples/dependency-graph/dashboard-view": resolve(
__dirname,
"../../plugins/fusion-plugin-dependency-graph/src/dashboard-view.tsx",

View File

@@ -224,6 +224,10 @@ export default defineConfig({
"@fusion/engine": resolve(__dirname, "../engine/src/index.ts"),
"@fusion/plugin-sdk": resolve(__dirname, "../plugin-sdk/src/index.ts"),
"@fusion/test-utils": resolve(__dirname, "../core/src/__test-utils__/workspace.ts"),
"@fusion/dashboard/app/components/TaskCard": resolve(__dirname, "app/components/TaskCard.tsx"),
"@fusion/dashboard/app/plugins/types": resolve(__dirname, "app/plugins/types.ts"),
"@fusion/dashboard/app/utils/projectStorage": resolve(__dirname, "app/utils/projectStorage.ts"),
"@fusion/dashboard/app/utils/taskStuck": resolve(__dirname, "app/utils/taskStuck.ts"),
"@fusion-plugin-examples/droid-runtime/probe": resolve(
__dirname,
"../../plugins/fusion-plugin-droid-runtime/src/probe.ts",

View File

@@ -24,7 +24,6 @@
},
"dependencies": {
"@fusion/core": "workspace:*",
"@fusion/dashboard": "workspace:*",
"@fusion/plugin-sdk": "workspace:*",
"express": "^5.1.0",
"lucide-react": "^0.542.0",

View File

@@ -0,0 +1,19 @@
declare module "@fusion/dashboard/app/plugins/types" {
import type { ReactNode } from "react";
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
export type DetailTaskTab = "definition" | "logs" | "changes" | "comments" | "model" | "workflow" | "pr" | "retries";
export type PluginToastType = "success" | "error" | "warning" | "info";
export interface PluginDashboardViewContext {
projectId?: string;
tasks: Task[];
workflowSteps: WorkflowStep[];
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
renderTaskCard?: (task: Task | TaskDetail) => ReactNode;
addToast?: (message: string, type?: PluginToastType) => void;
}
export type PluginTaskView = `plugin:${string}:${string}`;
}

View File

@@ -3,8 +3,11 @@
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx"
"jsx": "react-jsx",
"paths": {
"@fusion/dashboard/app/plugins/types": ["./src/dashboard-interop.d.ts"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"],
"exclude": ["src/**/*.test.ts", "src/**/__tests__/**"]
}

View File

@@ -21,7 +21,6 @@
},
"dependencies": {
"@fusion/core": "workspace:*",
"@fusion/dashboard": "workspace:*",
"@fusion/plugin-sdk": "workspace:*",
"lucide-react": "^0.542.0"
},

View File

@@ -44,3 +44,9 @@ declare module "@fusion/dashboard/app/components/TaskCard" {
export function TaskCard(props: TaskCardProps): ReactElement;
}
declare module "@fusion/dashboard/app/utils/projectStorage" {
export function getScopedItem(baseKey: string, projectId?: string): string | null;
export function setScopedItem(baseKey: string, value: string, projectId?: string): void;
export function removeScopedItem(baseKey: string, projectId?: string): void;
}

View File

@@ -8,7 +8,8 @@
"paths": {
"@fusion/dashboard/app/components/TaskCard": ["./src/dashboard-interop.d.ts"],
"@fusion/dashboard/app/utils/taskStuck": ["./src/dashboard-interop.d.ts"],
"@fusion/dashboard/app/plugins/types": ["./src/dashboard-interop.d.ts"]
"@fusion/dashboard/app/plugins/types": ["./src/dashboard-interop.d.ts"],
"@fusion/dashboard/app/utils/projectStorage": ["./src/dashboard-interop.d.ts"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"],

View File

@@ -29,7 +29,6 @@
},
"dependencies": {
"@fusion/core": "workspace:*",
"@fusion/dashboard": "workspace:*",
"@fusion/plugin-sdk": "workspace:*",
"express": "^5.1.0",
"lucide-react": "^0.542.0",

View File

@@ -0,0 +1,19 @@
declare module "@fusion/dashboard/app/plugins/types" {
import type { ReactNode } from "react";
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
export type DetailTaskTab = "definition" | "logs" | "changes" | "comments" | "model" | "workflow" | "pr" | "retries";
export type PluginToastType = "success" | "error" | "warning" | "info";
export interface PluginDashboardViewContext {
projectId?: string;
tasks: Task[];
workflowSteps: WorkflowStep[];
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
renderTaskCard?: (task: Task | TaskDetail) => ReactNode;
addToast?: (message: string, type?: PluginToastType) => void;
}
export type PluginTaskView = `plugin:${string}:${string}`;
}

View File

@@ -3,8 +3,11 @@
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx"
"jsx": "react-jsx",
"paths": {
"@fusion/dashboard/app/plugins/types": ["./src/dashboard-interop.d.ts"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"],
"exclude": ["src/**/*.test.ts", "src/**/__tests__/**"]
}

9
pnpm-lock.yaml generated
View File

@@ -648,9 +648,6 @@ importers:
'@fusion/core':
specifier: workspace:*
version: link:../../packages/core
'@fusion/dashboard':
specifier: workspace:*
version: link:../../packages/dashboard
'@fusion/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
@@ -719,9 +716,6 @@ importers:
'@fusion/core':
specifier: workspace:*
version: link:../../packages/core
'@fusion/dashboard':
specifier: workspace:*
version: link:../../packages/dashboard
'@fusion/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
@@ -891,9 +885,6 @@ importers:
'@fusion/core':
specifier: workspace:*
version: link:../../packages/core
'@fusion/dashboard':
specifier: workspace:*
version: link:../../packages/dashboard
'@fusion/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk