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([]);
});
});