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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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/**/*"],
|
||||
|
||||
@@ -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/**/*"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fusion/core": "workspace:*",
|
||||
"@fusion/dashboard": "workspace:*",
|
||||
"@fusion/plugin-sdk": "workspace:*",
|
||||
"express": "^5.1.0",
|
||||
"lucide-react": "^0.542.0",
|
||||
|
||||
19
plugins/fusion-plugin-cli-printing-press/src/dashboard-interop.d.ts
vendored
Normal file
19
plugins/fusion-plugin-cli-printing-press/src/dashboard-interop.d.ts
vendored
Normal 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}`;
|
||||
}
|
||||
@@ -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__/**"]
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fusion/core": "workspace:*",
|
||||
"@fusion/dashboard": "workspace:*",
|
||||
"@fusion/plugin-sdk": "workspace:*",
|
||||
"lucide-react": "^0.542.0"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fusion/core": "workspace:*",
|
||||
"@fusion/dashboard": "workspace:*",
|
||||
"@fusion/plugin-sdk": "workspace:*",
|
||||
"express": "^5.1.0",
|
||||
"lucide-react": "^0.542.0",
|
||||
|
||||
19
plugins/fusion-plugin-roadmap/src/dashboard-interop.d.ts
vendored
Normal file
19
plugins/fusion-plugin-roadmap/src/dashboard-interop.d.ts
vendored
Normal 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}`;
|
||||
}
|
||||
@@ -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
9
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user