Compare commits
10 Commits
pr/engine
...
visualize-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14c1028a6c | ||
|
|
362f7d0de4 | ||
|
|
22c138ed1b | ||
|
|
c459a25473 | ||
|
|
0a7448e829 | ||
|
|
3f955088b7 | ||
|
|
5debb11004 | ||
|
|
728e0a22ea | ||
|
|
acc45003a1 | ||
|
|
8e10249d6f |
562
.dependency-cruiser.mjs
Normal file
562
.dependency-cruiser.mjs
Normal file
@@ -0,0 +1,562 @@
|
||||
import { elements, rules } from "./frontend/lint/module-boundaries.mjs";
|
||||
|
||||
const tierColors = {
|
||||
lib: "#e8f5e9",
|
||||
basic: "#e3f2fd",
|
||||
shared: "#fff3e0",
|
||||
feature: "#fce4ec",
|
||||
app: "#f3e5f5",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers: derive dep-cruiser config from boundaries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A type can have several elements (a dir pattern plus loose entry files),
|
||||
// so keep them all — a last-wins map would drop the dir pattern.
|
||||
const typeToElements = new Map();
|
||||
for (const e of elements) {
|
||||
if (!typeToElements.has(e.type)) typeToElements.set(e.type, []);
|
||||
typeToElements.get(e.type).push(e);
|
||||
}
|
||||
const allTypes = [...typeToElements.keys()];
|
||||
|
||||
/** Types eligible for forbidden-rule analysis (excludes catch-alls) */
|
||||
const analysisTypes = allTypes.filter((t) => t !== "other" && t !== "app/misc");
|
||||
|
||||
function getTier(type) {
|
||||
const idx = type.indexOf("/");
|
||||
return idx >= 0 ? type.slice(0, idx) : type;
|
||||
}
|
||||
|
||||
function getName(type) {
|
||||
const idx = type.indexOf("/");
|
||||
return idx >= 0 ? type.slice(idx + 1) : type;
|
||||
}
|
||||
|
||||
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
/** Path prefix covered by an element pattern: a dir with trailing slash, or an exact file path */
|
||||
function patternPrefix(pattern) {
|
||||
return pattern
|
||||
.replace(/\/\*\.\*$/, "/")
|
||||
.replace(/\/\*\*$/, "/")
|
||||
.replace(/\*\//, "");
|
||||
}
|
||||
|
||||
const analysisPrefixes = analysisTypes.flatMap((type) =>
|
||||
typeToElements.get(type).map((el) => ({
|
||||
type,
|
||||
prefix: patternPrefix(el.pattern),
|
||||
})),
|
||||
);
|
||||
|
||||
/**
|
||||
* ^-anchored regexes for elements physically nested inside the dirs of
|
||||
* `types` without belonging to them (e.g. app/embedding-iframe-sdk inside
|
||||
* shared/embedding's dir, basic/mlv1 inside lib/mlv2's dir). Used as pathNot
|
||||
* so a broad dir regex doesn't swallow the nested module's files — the
|
||||
* eslint-boundaries plugin resolves this with first-match-wins precedence,
|
||||
* which plain regexes can't express.
|
||||
*/
|
||||
function nestedForeignPaths(types) {
|
||||
const typeSet = new Set(types);
|
||||
const dirs = analysisPrefixes
|
||||
.filter((p) => typeSet.has(p.type) && p.prefix.endsWith("/"))
|
||||
.map((p) => p.prefix);
|
||||
// app/misc claims loose files inside otherwise-claimed dirs (e.g.
|
||||
// new/components/NewModals) — always foreign here since app/misc is
|
||||
// never part of a rule's type set.
|
||||
const pool = [
|
||||
...analysisPrefixes,
|
||||
...(typeToElements.get("app/misc") ?? [])
|
||||
.filter((el) => !el.pattern.includes("*"))
|
||||
.map((el) => ({ type: "app/misc", prefix: el.pattern })),
|
||||
];
|
||||
const out = [];
|
||||
for (const { type, prefix } of pool) {
|
||||
if (typeSet.has(type)) continue;
|
||||
if (dirs.some((d) => prefix !== d && prefix.startsWith(d))) {
|
||||
out.push(`^${escapeRegex(prefix)}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** dep-cruiser from/to matcher ({ path, pathNot? }) for a set of element types */
|
||||
function typesToMatcher(types) {
|
||||
const path = typesToRegex(types);
|
||||
if (!path) return null;
|
||||
const pathNot = nestedForeignPaths(types);
|
||||
return pathNot.length > 0 ? { path, pathNot } : { path };
|
||||
}
|
||||
|
||||
/** Expand a boundaries wildcard pattern to matching element types */
|
||||
function expandWildcard(pattern) {
|
||||
if (pattern === "*") return [...allTypes];
|
||||
if (pattern.endsWith("/*")) {
|
||||
const prefix = pattern.slice(0, -1); // "lib/" from "lib/*"
|
||||
return allTypes.filter((t) => t.startsWith(prefix));
|
||||
}
|
||||
return allTypes.includes(pattern) ? [pattern] : [];
|
||||
}
|
||||
|
||||
/** Compute the full set of types an element is allowed to import */
|
||||
function computeAllowed(elementType) {
|
||||
const allowed = new Set();
|
||||
for (const rule of rules) {
|
||||
// importKind: "type" allows are treated as full allows — this version of
|
||||
// dep-cruiser doesn't reliably tag type-only imports, so we can't scope
|
||||
// the exemption. The dependencyTypesNot below is a no-op today but will
|
||||
// narrow these correctly if tagging starts working.
|
||||
const froms = rule.from.flatMap(expandWildcard);
|
||||
if (froms.includes(elementType)) {
|
||||
rule.allow.flatMap(expandWildcard).forEach((t) => allowed.add(t));
|
||||
}
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a list of element types to a combined dep-cruiser path regex.
|
||||
*
|
||||
* Groups metabase/<name>/ paths together and keeps top-level dirs
|
||||
* (metabase-types, metabase-lib) separate, producing readable patterns like:
|
||||
* ^frontend/src/(metabase-lib|metabase/(ui|api))/
|
||||
*/
|
||||
function typesToRegex(types) {
|
||||
const metabaseSubdirs = [];
|
||||
const topLevelDirs = [];
|
||||
const rawPatterns = [];
|
||||
const filePatterns = [];
|
||||
|
||||
for (const type of types) {
|
||||
for (const el of typeToElements.get(type) ?? []) {
|
||||
const subMatch = el.pattern.match(
|
||||
/^frontend\/src\/metabase\/([\w-]+)\/\*\*$/,
|
||||
);
|
||||
if (subMatch) {
|
||||
metabaseSubdirs.push(subMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const topMatch = el.pattern.match(
|
||||
/^frontend\/src\/([\w-]+)\/(?:\*\/)?\*\*$/,
|
||||
);
|
||||
if (topMatch) {
|
||||
topLevelDirs.push(topMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Single-file elements (mode: "full") match by exact path
|
||||
if (!el.pattern.includes("*")) {
|
||||
filePatterns.push(escapeRegex(el.pattern));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle patterns outside frontend/src/metabase/ (e.g. enterprise/frontend/src/metabase-enterprise/**)
|
||||
// Skip patterns with globs in the directory path (like frontend/src/*/**)
|
||||
const rawMatch = el.pattern.match(/^([^*]+)\/\*\*$/);
|
||||
if (rawMatch) {
|
||||
rawPatterns.push(rawMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parts = [...topLevelDirs];
|
||||
if (metabaseSubdirs.length === 1) {
|
||||
parts.push(`metabase/${metabaseSubdirs[0]}`);
|
||||
} else if (metabaseSubdirs.length > 1) {
|
||||
parts.push(`metabase/(${metabaseSubdirs.join("|")})`);
|
||||
}
|
||||
|
||||
// Build frontend/src/ regex
|
||||
const frontendRegex =
|
||||
parts.length === 0
|
||||
? null
|
||||
: parts.length === 1
|
||||
? `^frontend/src/${parts[0]}/`
|
||||
: `^frontend/src/(${parts.join("|")})/`;
|
||||
|
||||
// Build raw patterns regex (for paths outside frontend/src/metabase/)
|
||||
const rawRegex =
|
||||
rawPatterns.length === 0
|
||||
? null
|
||||
: rawPatterns.length === 1
|
||||
? `^${rawPatterns[0]}/`
|
||||
: `^(${rawPatterns.join("|")})/`;
|
||||
|
||||
const fileRegex =
|
||||
filePatterns.length === 0 ? null : `^(${filePatterns.join("|")})$`;
|
||||
|
||||
const pieces = [frontendRegex, rawRegex, fileRegex].filter(Boolean);
|
||||
if (pieces.length === 0) return null;
|
||||
return pieces.length === 1 ? pieces[0] : `(${pieces.join("|")})`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generate forbidden rules by inverting the boundaries allowlist
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateForbiddenRules() {
|
||||
// For each analysis type, compute its cross-tier forbidden targets
|
||||
const tierForbiddenMap = new Map(); // tier → Set<forbidden analysis types>
|
||||
|
||||
for (const type of analysisTypes) {
|
||||
const tier = getTier(type);
|
||||
const allowed = computeAllowed(type);
|
||||
const forbidden = analysisTypes.filter(
|
||||
(t) => t !== type && !allowed.has(t),
|
||||
);
|
||||
|
||||
if (!tierForbiddenMap.has(tier)) tierForbiddenMap.set(tier, new Map());
|
||||
tierForbiddenMap.get(tier).set(type, forbidden);
|
||||
}
|
||||
|
||||
const result = [];
|
||||
|
||||
// --- Cross-tier rules ---
|
||||
// Elements in the same tier share the same cross-tier forbidden targets,
|
||||
// so group them into one rule per source tier.
|
||||
for (const [tier, typeMap] of tierForbiddenMap) {
|
||||
const tierTypes = [...typeMap.keys()];
|
||||
|
||||
// Group tier members by identical cross-tier forbidden sets — per-element
|
||||
// exceptions (whitelisted edges like admin-theme-preview → iframe-sdk)
|
||||
// mean a tier is not always homogeneous, and a tier-wide union would
|
||||
// flag the excepted element's allowed imports.
|
||||
const crossGroups = new Map();
|
||||
for (const [type, forbidden] of typeMap) {
|
||||
const cross = forbidden.filter((f) => getTier(f) !== tier).sort();
|
||||
if (cross.length === 0) continue;
|
||||
const key = cross.join("|");
|
||||
if (!crossGroups.has(key)) crossGroups.set(key, { types: [], cross });
|
||||
crossGroups.get(key).types.push(type);
|
||||
}
|
||||
|
||||
for (const { types: groupTypes, cross } of crossGroups.values()) {
|
||||
const fromMatcher = typesToMatcher(groupTypes);
|
||||
const toMatcher = typesToMatcher(cross);
|
||||
|
||||
if (fromMatcher && toMatcher) {
|
||||
const targetTiers = [...new Set(cross.map(getTier))].sort();
|
||||
|
||||
result.push({
|
||||
name: `no-${tier}-to-${targetTiers.join("-")}`,
|
||||
comment: `${tier} modules should not import from ${targetTiers.join("/")} modules`,
|
||||
severity: "error",
|
||||
from: fromMatcher,
|
||||
to: toMatcher,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Same-tier cross-import rules ---
|
||||
// Check if any element in this tier is forbidden from importing other
|
||||
// same-tier elements (e.g. feature→feature, basic→basic)
|
||||
const hasCrossImportRestriction = [...typeMap.values()].some((forbidden) =>
|
||||
forbidden.some((f) => getTier(f) === tier),
|
||||
);
|
||||
|
||||
if (hasCrossImportRestriction && tierTypes.length >= 2) {
|
||||
// For each pair of types in this tier, check whether cross-imports
|
||||
// are actually forbidden (respecting per-element exceptions like
|
||||
// enterprise being allowed to import other features).
|
||||
|
||||
// Split into "simple" types (a single metabase/<name>/ dir whose name
|
||||
// matches the type name, so the $1-backreference trick works) and
|
||||
// "special" types (everything else: multi-pattern, hyphen-mismatched,
|
||||
// file-based, or outside frontend/src/metabase/).
|
||||
const isSimple = (t) => {
|
||||
const els = typeToElements.get(t);
|
||||
if (els.length !== 1) return false;
|
||||
const m = els[0].pattern.match(/^frontend\/src\/metabase\/([\w-]+)\/\*\*$/);
|
||||
return m !== null && m[1] === getName(t);
|
||||
};
|
||||
const simpleTypes = tierTypes.filter(isSimple);
|
||||
const specialTypes = tierTypes.filter((t) => !isSimple(t));
|
||||
|
||||
// Simple types all have identical cross-import restrictions,
|
||||
// so use pathNot backreference for efficiency
|
||||
const restrictedSimple = simpleTypes.filter((t) =>
|
||||
typeMap.get(t)?.some((f) => getTier(f) === tier),
|
||||
);
|
||||
if (restrictedSimple.length >= 2) {
|
||||
const names = restrictedSimple.map(getName);
|
||||
const regex = `^frontend/src/metabase/(${names.join("|")})/`;
|
||||
const nested = nestedForeignPaths(restrictedSimple);
|
||||
result.push({
|
||||
name: `no-${tier}-cross-import`,
|
||||
comment: `${tier} modules should not import from other ${tier} modules`,
|
||||
severity: "error",
|
||||
from: nested.length > 0 ? { path: regex, pathNot: nested } : { path: regex },
|
||||
to: { path: regex, pathNot: [`^frontend/src/metabase/$1/`, ...nested] },
|
||||
});
|
||||
}
|
||||
|
||||
// Cross-rules between simple and special types — only add
|
||||
// a rule if that specific direction is actually forbidden
|
||||
for (const special of specialTypes) {
|
||||
const specialForbidden = typeMap.get(special) ?? [];
|
||||
const simpleForbidden = simpleTypes.filter((s) =>
|
||||
(typeMap.get(s) ?? []).includes(special),
|
||||
);
|
||||
|
||||
// special → simple (only if special is forbidden from importing simple)
|
||||
const forbiddenSimpleTargets = simpleTypes.filter((s) =>
|
||||
specialForbidden.includes(s),
|
||||
);
|
||||
if (forbiddenSimpleTargets.length > 0) {
|
||||
const toMatcher = typesToMatcher(forbiddenSimpleTargets);
|
||||
const fromMatcher = typesToMatcher([special]);
|
||||
if (fromMatcher && toMatcher) {
|
||||
result.push({
|
||||
name: `no-${tier}-cross-import`,
|
||||
comment: `${tier} modules should not import from other ${tier} modules`,
|
||||
severity: "error",
|
||||
from: fromMatcher,
|
||||
to: toMatcher,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// simple → special (only if simple types are forbidden from importing special)
|
||||
if (simpleForbidden.length > 0) {
|
||||
const fromMatcher = typesToMatcher(simpleForbidden);
|
||||
const toMatcher = typesToMatcher([special]);
|
||||
if (fromMatcher && toMatcher) {
|
||||
result.push({
|
||||
name: `no-${tier}-cross-import`,
|
||||
comment: `${tier} modules should not import from other ${tier} modules`,
|
||||
severity: "error",
|
||||
from: fromMatcher,
|
||||
to: toMatcher,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-rules among special types
|
||||
for (let i = 0; i < specialTypes.length; i++) {
|
||||
for (let j = i + 1; j < specialTypes.length; j++) {
|
||||
const iForbidden = typeMap.get(specialTypes[i]) ?? [];
|
||||
const jForbidden = typeMap.get(specialTypes[j]) ?? [];
|
||||
const matcherI = typesToMatcher([specialTypes[i]]);
|
||||
const matcherJ = typesToMatcher([specialTypes[j]]);
|
||||
if (iForbidden.includes(specialTypes[j]) && matcherI && matcherJ) {
|
||||
result.push({
|
||||
name: `no-${tier}-cross-import`,
|
||||
severity: "error",
|
||||
from: matcherI,
|
||||
to: matcherJ,
|
||||
});
|
||||
}
|
||||
if (jForbidden.includes(specialTypes[i]) && matcherI && matcherJ) {
|
||||
result.push({
|
||||
name: `no-${tier}-cross-import`,
|
||||
severity: "error",
|
||||
from: matcherJ,
|
||||
to: matcherI,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- shared/other rules ---
|
||||
// shared/other is a catch-all for unnamed frontend/src/ dirs.
|
||||
// It follows shared-tier rules: can import lib/basic/shared, cannot import feature/app.
|
||||
const otherEl = elements.find((e) => e.type === "shared/other");
|
||||
if (otherEl) {
|
||||
const otherAllowed = computeAllowed("shared/other");
|
||||
const otherForbidden = analysisTypes.filter(
|
||||
(t) => !otherAllowed.has(t) && t !== "shared/other",
|
||||
);
|
||||
if (otherForbidden.length > 0) {
|
||||
const toMatcher = typesToMatcher(otherForbidden);
|
||||
// Exclude every named element (dir prefix or exact file), plus app/misc
|
||||
// files directly under frontend/src/metabase/ (they follow app/* rules
|
||||
// which allow everything).
|
||||
const namedPath = [
|
||||
...analysisPrefixes.map(({ prefix }) =>
|
||||
prefix.endsWith("/")
|
||||
? `^${escapeRegex(prefix)}`
|
||||
: `^${escapeRegex(prefix)}$`,
|
||||
),
|
||||
"^frontend/src/metabase/[^/]+$",
|
||||
];
|
||||
|
||||
if (toMatcher) {
|
||||
result.push({
|
||||
name: "no-other-to-feature",
|
||||
comment: "Unnamed shared modules should not import from feature/app modules",
|
||||
severity: "error",
|
||||
from: {
|
||||
path: "^frontend/src/",
|
||||
pathNot: namedPath,
|
||||
},
|
||||
to: toMatcher,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-boundaries ignores type-only imports (they're erased at compile
|
||||
// time), so exempt them from every boundary rule to match.
|
||||
return result.map((rule) => ({
|
||||
...rule,
|
||||
to: { ...rule.to, dependencyTypesNot: ["type-only"] },
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generate includeOnly regex from elements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateIncludeOnly() {
|
||||
// One ^-anchored prefix per element (dir or exact file). frontend/src/metabase/
|
||||
// is included wholesale so not-yet-assigned dirs still show up (they render
|
||||
// as "other" and are checked by the no-other-to-feature rule).
|
||||
const prefixes = new Set(["^frontend/src/metabase/"]);
|
||||
for (const { prefix } of analysisPrefixes) {
|
||||
prefixes.add(
|
||||
prefix.endsWith("/") ? `^${escapeRegex(prefix)}` : `^${escapeRegex(prefix)}$`,
|
||||
);
|
||||
}
|
||||
return [...prefixes].join("|");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generate reporter module themes from elements + tierColors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateModuleThemes() {
|
||||
const themes = [];
|
||||
|
||||
for (const el of elements) {
|
||||
if (el.type === "other" || el.type === "app/misc") continue;
|
||||
|
||||
const tier = getTier(el.type);
|
||||
const color = tierColors[tier];
|
||||
if (!color) continue;
|
||||
|
||||
let sourceRegex;
|
||||
let isTopLevel = false;
|
||||
|
||||
const subMatch = el.pattern.match(/^frontend\/src\/metabase\/(\w+)\/\*\*$/);
|
||||
if (subMatch) {
|
||||
sourceRegex = `^frontend/src/metabase/${subMatch[1]}/`;
|
||||
} else {
|
||||
const topMatch = el.pattern.match(
|
||||
/^frontend\/src\/([\w-]+)\/(?:\*\/)?\*\*$/,
|
||||
);
|
||||
if (topMatch) {
|
||||
sourceRegex = `^frontend/src/${topMatch[1]}/`;
|
||||
isTopLevel = true;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const attrs = { fillcolor: color };
|
||||
// Top-level dirs (metabase-types, metabase-lib) need a label override
|
||||
// since the collapsed path shows the full directory name
|
||||
if (isTopLevel) {
|
||||
attrs.label = getName(el.type);
|
||||
}
|
||||
|
||||
themes.push({
|
||||
criteria: { source: sourceRegex },
|
||||
attributes: attrs,
|
||||
});
|
||||
}
|
||||
|
||||
return themes;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generate collapse pattern from elements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateCollapsePattern() {
|
||||
// Top-level dirs need an extra segment collapsed (e.g. metabase-types/api/*)
|
||||
const topLevel = [];
|
||||
const metabaseDirs = [];
|
||||
|
||||
for (const type of analysisTypes) {
|
||||
for (const el of typeToElements.get(type) ?? []) {
|
||||
const topMatch = el.pattern.match(
|
||||
/^frontend\/src\/([\w-]+)\/(?:\*\/)?\*\*$/,
|
||||
);
|
||||
if (topMatch) {
|
||||
topLevel.push(topMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (topLevel.length > 0) {
|
||||
return `^frontend/src/(${topLevel.join("|")})/[^/]+/|^frontend/src/metabase/([^/]+)/`;
|
||||
}
|
||||
return `^frontend/src/metabase/([^/]+)/`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @type {import('dependency-cruiser').IConfiguration} */
|
||||
export default {
|
||||
forbidden: [
|
||||
{
|
||||
name: "no-circular",
|
||||
comment: "No circular dependencies allowed between modules",
|
||||
severity: "error",
|
||||
from: {},
|
||||
to: { circular: true },
|
||||
},
|
||||
...generateForbiddenRules(),
|
||||
],
|
||||
|
||||
options: {
|
||||
cache: {
|
||||
folder: "node_modules/.cache/dependency-cruiser",
|
||||
strategy: "content",
|
||||
},
|
||||
doNotFollow: {
|
||||
path: "node_modules",
|
||||
},
|
||||
includeOnly: {
|
||||
path: generateIncludeOnly(),
|
||||
},
|
||||
exclude: {
|
||||
path: ["\\.(unit\\.spec|stories)\\.", "(^|/)e2e/", "(^|/)test/"],
|
||||
},
|
||||
tsPreCompilationDeps: true,
|
||||
tsConfig: {
|
||||
fileName: "tsconfig.json",
|
||||
},
|
||||
enhancedResolveOptions: {
|
||||
exportsFields: ["exports"],
|
||||
conditionNames: ["import", "require", "node", "default"],
|
||||
},
|
||||
reporterOptions: {
|
||||
dot: {
|
||||
collapsePattern: generateCollapsePattern(),
|
||||
theme: {
|
||||
graph: {
|
||||
rankdir: "TB",
|
||||
splines: "ortho",
|
||||
},
|
||||
modules: generateModuleThemes(),
|
||||
dependencies: [
|
||||
{
|
||||
criteria: { "rules[0].severity": "error" },
|
||||
attributes: { color: "red", fontcolor: "red" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
38
.dependency-cruiser.stories.mjs
Normal file
38
.dependency-cruiser.stories.mjs
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Dep-cruiser config for finding affected story files.
|
||||
* Used by scripts/affected-stories.mjs with the --affected flag.
|
||||
*
|
||||
* Differences from main config:
|
||||
* - No forbidden rules (we only need the dependency graph)
|
||||
* - Broader includeOnly (all of frontend/src/)
|
||||
* - Stories are NOT excluded (they are what we want to find)
|
||||
*/
|
||||
|
||||
/** @type {import('dependency-cruiser').IConfiguration} */
|
||||
export default {
|
||||
forbidden: [],
|
||||
|
||||
options: {
|
||||
cache: {
|
||||
folder: "node_modules/.cache/dependency-cruiser-stories",
|
||||
strategy: "content",
|
||||
},
|
||||
doNotFollow: {
|
||||
path: "node_modules",
|
||||
},
|
||||
includeOnly: {
|
||||
path: "^frontend/src/",
|
||||
},
|
||||
exclude: {
|
||||
path: ["(^|/)e2e/", "(^|/)test/"],
|
||||
},
|
||||
tsPreCompilationDeps: true,
|
||||
tsConfig: {
|
||||
fileName: "tsconfig.json",
|
||||
},
|
||||
enhancedResolveOptions: {
|
||||
exportsFields: ["exports"],
|
||||
conditionNames: ["import", "require", "node", "default"],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -78,6 +78,19 @@ export default defineConfig([
|
||||
settings: {
|
||||
"boundaries/elements": boundaryElements,
|
||||
"boundaries/ignore": ["**/e2e/**", "test/**"],
|
||||
// Uncomment to also enforce boundary rules on TypeScript type-only
|
||||
// imports (`import type { ... } from "..."`). Off by default since the
|
||||
// plugin ignores type imports — they're erased at compile time and
|
||||
// create no runtime coupling — but turning this on catches conceptual
|
||||
// coupling (e.g. mlv1 importing types from custom-viz). Expect a
|
||||
// backlog of newly-surfaced violations on first run.
|
||||
// "boundaries/additional-dependency-nodes": [
|
||||
// {
|
||||
// selector: "ImportDeclaration[importKind=type] > Literal",
|
||||
// name: "type-import",
|
||||
// kind: "type",
|
||||
// },
|
||||
// ],
|
||||
"import-x/resolver": {
|
||||
node: true,
|
||||
webpack: {
|
||||
|
||||
3641
module-boundaries-shared.svg
Normal file
3641
module-boundaries-shared.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 200 KiB |
10880
module-boundaries.svg
Normal file
10880
module-boundaries.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 588 KiB |
90
module-edges.md
Normal file
90
module-edges.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Module Edge Counts
|
||||
|
||||
Each row shows outgoing edges (where this module imports from another) and incoming edges (where another module imports from this one), grouped by the destination/source tier.
|
||||
|
||||
Tiers shown are the base tier from `frontend/lint/module-boundaries.mjs` (not the visualization sub-tier split).
|
||||
|
||||
## App
|
||||
|
||||
| Module | Out total | →App | →Feat | →Shar | →Basi | →Lib | →Othe | In total | App→ | Feat→ | Shar→ | Basi→ | Lib→ | Othe→ |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| `misc` | 37 | | 5 | 27 | 1 | 4 | | | | | | | | |
|
||||
|
||||
## Feature
|
||||
|
||||
| Module | Out total | →App | →Feat | →Shar | →Basi | →Lib | →Othe | In total | App→ | Feat→ | Shar→ | Basi→ | Lib→ | Othe→ |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| `admin` | 23 | | | 18 | 1 | 4 | | 3 | 1 | 1 | 1 | | | |
|
||||
| `dashboard` | 20 | | | 15 | 1 | 4 | | 6 | 1 | 2 | 3 | | | |
|
||||
| `enterprise` | 40 | | 3 | 31 | 1 | 5 | | | | | | | | |
|
||||
| `public` | 14 | | 2 | 8 | 1 | 3 | | 1 | 1 | | | | | |
|
||||
| `query_builder` | 22 | | | 17 | 1 | 4 | | 7 | 1 | 2 | 4 | | | |
|
||||
| `reference` | 12 | | | 7 | 1 | 4 | | 2 | 1 | | 1 | | | |
|
||||
|
||||
## Shared
|
||||
|
||||
| Module | Out total | →App | →Feat | →Shar | →Basi | →Lib | →Othe | In total | App→ | Feat→ | Shar→ | Basi→ | Lib→ | Othe→ |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| `account` | 12 | | | 8 | 1 | 3 | | 2 | 1 | | 1 | | | |
|
||||
| `api` | 8 | | | 3 | | 5 | | 34 | 1 | 5 | 28 | | | |
|
||||
| `archive` | 7 | | | 4 | 1 | 2 | | 13 | | 4 | 9 | | | |
|
||||
| `auth` | 10 | | | 7 | 1 | 2 | | 3 | 1 | 1 | 1 | | | |
|
||||
| `browse` | 15 | | | 10 | 1 | 4 | | 5 | 1 | 1 | 3 | | | |
|
||||
| `collections` | 17 | | | 12 | 1 | 4 | | 23 | 1 | 4 | 18 | | | |
|
||||
| `comments` | 8 | | | 4 | 1 | 3 | | 1 | | | 1 | | | |
|
||||
| `common` | 25 | | | 19 | 1 | 5 | | 41 | 1 | 6 | 34 | | | |
|
||||
| `custom-viz` | | | | | | | | 1 | | 1 | | | | |
|
||||
| `data-grid` | 4 | | | 2 | 1 | 1 | | 2 | | 1 | 1 | | | |
|
||||
| `data-studio` | 15 | | | 10 | 1 | 4 | | 10 | 1 | 2 | 7 | | | |
|
||||
| `databases` | 10 | | | 5 | 1 | 4 | | 7 | | 3 | 4 | | | |
|
||||
| `detail-view` | 11 | | | 7 | 1 | 3 | | 4 | 1 | | 3 | | | |
|
||||
| `documents` | 17 | | | 12 | 1 | 4 | | 4 | 1 | 1 | 2 | | | |
|
||||
| `embedding` | 17 | | 2 | 10 | 1 | 4 | | 15 | 1 | 5 | 8 | | 1 | |
|
||||
| `embedding-ee` | 10 | | | 6 | 1 | 3 | | 2 | | 1 | 1 | | | |
|
||||
| `embedding-sdk-package` | 6 | | 1 | 2 | 1 | 2 | | 1 | | 1 | | | | |
|
||||
| `embedding-sdk-shared` | | | | | | | | | | | | | | |
|
||||
| `forms` | 7 | | | 3 | 1 | 3 | | 17 | | 4 | 13 | | | |
|
||||
| `history` | | | | | | | | 2 | 1 | | 1 | | | |
|
||||
| `hoc` | 4 | | | 2 | 1 | 1 | | 8 | 1 | 2 | 5 | | | |
|
||||
| `home` | 13 | | | 8 | 1 | 4 | | 2 | 1 | | 1 | | | |
|
||||
| `hooks` | 5 | | | 4 | | 1 | | 18 | 1 | 5 | 12 | | | |
|
||||
| `i18n` | 2 | | | 1 | | 1 | | 10 | | 3 | 7 | | | |
|
||||
| `metabase-shared` | | | | | | | | | | | | | | |
|
||||
| `metadata` | 11 | | | 7 | 1 | 3 | | 11 | | 4 | 7 | | | |
|
||||
| `metrics` | 17 | | | 13 | 1 | 3 | | 5 | 1 | 1 | 3 | | | |
|
||||
| `metrics-viewer` | 12 | | | 7 | 1 | 4 | | 2 | 1 | | 1 | | | |
|
||||
| `new` | 7 | | | 6 | | 1 | | 1 | 1 | | | | | |
|
||||
| `other` | 42 | | 4 | 31 | 1 | 6 | | 43 | 1 | 6 | 34 | | 2 | |
|
||||
| `palette` | 13 | | | 8 | 1 | 4 | | 7 | 1 | 2 | 4 | | | |
|
||||
| `pulse` | 4 | | | 2 | | 2 | | 5 | | 2 | 3 | | | |
|
||||
| `querying` | 18 | | 1 | 12 | 1 | 4 | | 15 | | 4 | 11 | | | |
|
||||
| `questions` | 12 | | | 9 | 1 | 2 | | 8 | | 3 | 5 | | | |
|
||||
| `router` | 1 | | | 1 | | | | 4 | 1 | 2 | 1 | | | |
|
||||
| `search` | 12 | | | 8 | 1 | 3 | | 6 | 1 | 1 | 4 | | | |
|
||||
| `setup` | 11 | | | 6 | 1 | 4 | | 9 | 1 | 2 | 6 | | | |
|
||||
| `status` | 6 | | | 3 | 1 | 2 | | 4 | 1 | 2 | 1 | | | |
|
||||
| `styled-components` | 8 | | | 4 | 1 | 3 | | 14 | 1 | 4 | 9 | | | |
|
||||
| `timelines` | 13 | | | 9 | 1 | 3 | | 2 | 1 | 1 | | | | |
|
||||
| `transforms` | 19 | | | 13 | 1 | 5 | | 5 | 1 | 1 | 3 | | | |
|
||||
| `types` | | | | | | | | | | | | | | |
|
||||
| `urls` | 7 | | | 5 | | 2 | | 29 | | 5 | 24 | | | |
|
||||
| `visualizations` | 19 | | 1 | 12 | 1 | 5 | | 26 | 1 | 6 | 19 | | | |
|
||||
|
||||
## Basic
|
||||
|
||||
| Module | Out total | →App | →Feat | →Shar | →Basi | →Lib | →Othe | In total | App→ | Feat→ | Shar→ | Basi→ | Lib→ | Othe→ |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| `mlv1` | | | | | | | | | | | | | | |
|
||||
| `ui` | 4 | | | | | 4 | | 39 | 1 | 6 | 32 | | | |
|
||||
|
||||
## Lib
|
||||
|
||||
| Module | Out total | →App | →Feat | →Shar | →Basi | →Lib | →Othe | In total | App→ | Feat→ | Shar→ | Basi→ | Lib→ | Othe→ |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| `analytics` | 3 | | | | | 3 | | 25 | 1 | 5 | 19 | | | |
|
||||
| `css` | | | | | | | | 30 | 1 | 6 | 22 | 1 | | |
|
||||
| `env` | | | | | | | | 10 | | 1 | 6 | 1 | 2 | |
|
||||
| `metabase-types` | 3 | | | 2 | | 1 | | 46 | 1 | 6 | 36 | 1 | 2 | |
|
||||
| `mlv2` | | | | | | | | | | | | | | |
|
||||
| `schema` | 1 | | | 1 | | | | 2 | | | 2 | | | |
|
||||
| `utils` | 2 | | | | | 2 | | 44 | 1 | 6 | 34 | 1 | 2 | |
|
||||
@@ -536,6 +536,9 @@
|
||||
"lint-eslint": "bun run build:cljs && bun run lint-eslint-pure",
|
||||
"lint-eslint-pure": "eslint --cache --cache-strategy content --max-warnings 0 --report-unused-disable-directives enterprise/frontend frontend e2e",
|
||||
"module-boundaries": "eslint --config eslint.config.module-boundaries.mjs 'frontend/src/**/*.{js,jsx,ts,tsx}' 'enterprise/frontend/src/**/*.{js,jsx,ts,tsx}'",
|
||||
"module-graph": "node scripts/module-graph.mjs > module-boundaries.svg",
|
||||
"module-graph-shared": "node scripts/module-graph.mjs --shared-only > module-boundaries-shared.svg",
|
||||
"module-validate": "bunx depcruise frontend/src/metabase/lib frontend/src/metabase/ui frontend/src/metabase/api frontend/src/metabase/common frontend/src/metabase/querying frontend/src/metabase/visualizations frontend/src/metabase/dashboard frontend/src/metabase/query_builder frontend/src/metabase/admin frontend/src/metabase/reference frontend/src/metabase-types frontend/src/metabase-lib --config .dependency-cruiser.mjs --output-type err",
|
||||
"lint-format": "bun install && bun lint-format-pure",
|
||||
"lint-format-pure": "oxfmt --check '{frontend,enterprise/frontend,e2e}/**/*.{js,jsx,ts,tsx,css}'",
|
||||
"lint-yaml": "yamllint **/*.{yaml,yml} --ignore=node_modules/**/*.{yaml,yml}",
|
||||
|
||||
72
scripts/affected-stories.mjs
Normal file
72
scripts/affected-stories.mjs
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Finds Loki story files affected by changes in a PR.
|
||||
*
|
||||
* Uses dependency-cruiser's --affected flag to find all modules that
|
||||
* transitively depend on changed files, then filters to story files.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/affected-stories.mjs [base_ref]
|
||||
*
|
||||
* Output:
|
||||
* Comma-separated story file paths on stdout (for STORYBOOK_STORIES_FILTER)
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 - paths on stdout (or empty if no stories affected)
|
||||
* 2 - all stories affected, run everything
|
||||
*/
|
||||
|
||||
import { execSync } from "child_process";
|
||||
|
||||
const baseRef = process.argv[2] || "origin/master";
|
||||
const ALL_STORIES_THRESHOLD = 0.8;
|
||||
|
||||
function isStoryFile(filePath) {
|
||||
return /\.stories\.(ts|tsx|js|jsx)$/.test(filePath);
|
||||
}
|
||||
|
||||
let graph;
|
||||
try {
|
||||
const output = execSync(
|
||||
`bunx depcruise frontend/src --affected ${baseRef} --config .dependency-cruiser.stories.mjs --output-type json`,
|
||||
{ encoding: "utf8", maxBuffer: 200 * 1024 * 1024 },
|
||||
);
|
||||
graph = JSON.parse(output);
|
||||
} catch {
|
||||
process.stderr.write("No frontend changes detected\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const affectedStories = graph.modules
|
||||
.map((m) => m.source)
|
||||
.filter(isStoryFile);
|
||||
|
||||
// Quick count of all stories to decide if we should just run everything.
|
||||
const allStoriesOutput = execSync(
|
||||
"find frontend/src -name '*.stories.ts' -o -name '*.stories.tsx' | grep -v node_modules || true",
|
||||
{ encoding: "utf8" },
|
||||
).trim();
|
||||
const totalStories = allStoriesOutput ? allStoriesOutput.split("\n").length : 0;
|
||||
|
||||
if (totalStories === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ratio = affectedStories.length / totalStories;
|
||||
|
||||
if (ratio >= ALL_STORIES_THRESHOLD) {
|
||||
process.stderr.write(
|
||||
`${affectedStories.length}/${totalStories} stories affected (${(ratio * 100).toFixed(0)}%) — run all\n`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (affectedStories.length > 0) {
|
||||
process.stderr.write(
|
||||
`${affectedStories.length}/${totalStories} stories affected (${(ratio * 100).toFixed(0)}%)\n`,
|
||||
);
|
||||
process.stdout.write(affectedStories.join(",") + "\n");
|
||||
} else {
|
||||
process.stderr.write("No stories affected\n");
|
||||
}
|
||||
916
scripts/module-graph.mjs
Normal file
916
scripts/module-graph.mjs
Normal file
@@ -0,0 +1,916 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Generates a module boundary dependency graph as SVG.
|
||||
*
|
||||
* Runs dependency-cruiser, renders the graph with Graphviz, and injects an
|
||||
* "Other modules" sidebar listing directories not yet assigned to a tier.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/module-graph.mjs > module-boundaries.svg
|
||||
* node scripts/module-graph.mjs --shared-only > module-boundaries-shared.svg
|
||||
*/
|
||||
|
||||
import { readdirSync, existsSync, readFileSync, unlinkSync } from "fs";
|
||||
import { execSync } from "child_process";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { tmpdir } from "os";
|
||||
import { elements } from "../frontend/lint/module-boundaries.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// --shared-only: render just the shared tier — its sub-tier rows and the
|
||||
// edges between shared modules — with nodes/edges colored by sub-tier.
|
||||
const SHARED_ONLY = process.argv.includes("--shared-only");
|
||||
|
||||
const tierColors = {
|
||||
lib: "#e8f5e9",
|
||||
basic: "#e3f2fd",
|
||||
shared: "#fff3e0",
|
||||
feature: "#fce4ec",
|
||||
app: "#e8eaf6",
|
||||
};
|
||||
|
||||
// Darker variants of each tier color for use as edge strokes (the pastel fill
|
||||
// colors above are too faint to read on a white background as lines).
|
||||
const tierEdgeColors = {
|
||||
lib: "#2e7d32", // pure green (was olive-ish #558b2f)
|
||||
basic: "#0288d1", // light blue 700 — cyan-leaning but still clearly blue
|
||||
shared: "#ffc107",
|
||||
feature: "#c2185b",
|
||||
app: "#3949ab",
|
||||
};
|
||||
|
||||
// Per-tier edge alpha (2-hex-digit suffix). Defaults to 80 (~50%); bump
|
||||
// individual tiers when their color reads as too faint against others.
|
||||
const tierEdgeAlpha = {
|
||||
basic: "b0", // cyan is light — bump opacity ~69% so it doesn't get drowned
|
||||
};
|
||||
const DEFAULT_EDGE_ALPHA = "80";
|
||||
|
||||
/**
|
||||
* How non-violation edges are coloured:
|
||||
* "none" — uniform gray (#00000055)
|
||||
* "source" — tier colour of the source module (where the edge starts)
|
||||
* "target" — tier colour of the destination module (where it ends)
|
||||
*/
|
||||
const EDGE_COLOR_MODE = "target";
|
||||
|
||||
// Per-sub-tier colors for --shared-only mode. In the full graph every shared
|
||||
// module shares one amber; a shared-only view needs per-row colors or it
|
||||
// reads as a monochrome hairball.
|
||||
const sharedSubtierColors = {
|
||||
shared_embedding: { fill: "#fff3e0", edge: "#ef6c00" }, // orange
|
||||
shared_apps: { fill: "#fce4ec", edge: "#c2185b" }, // pink
|
||||
shared_domain: { fill: "#e3f2fd", edge: "#0288d1" }, // blue
|
||||
shared_content: { fill: "#e8f5e9", edge: "#2e7d32" }, // green
|
||||
shared_infra: { fill: "#ede7f6", edge: "#5e35b1" }, // purple
|
||||
shared_utils: { fill: "#f5f5f5", edge: "#616161" }, // grey
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derive configuration from boundaries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getTier(type) {
|
||||
const idx = type.indexOf("/");
|
||||
return idx >= 0 ? type.slice(0, idx) : type;
|
||||
}
|
||||
|
||||
function getName(type) {
|
||||
const idx = type.indexOf("/");
|
||||
return idx >= 0 ? type.slice(idx + 1) : type;
|
||||
}
|
||||
|
||||
/** Extract the collapsed directory path from an element pattern. */
|
||||
function patternToDir(pattern) {
|
||||
return pattern
|
||||
.replace(/\/\*\.\*$/, "/")
|
||||
.replace(/\/\*\*$/, "/")
|
||||
.replace(/\*\//, "");
|
||||
}
|
||||
|
||||
/** Elements eligible for graph display (exclude catch-alls, deduplicate types) */
|
||||
const seenTypes = new Set();
|
||||
const graphElements = elements.filter((e) => {
|
||||
if (e.type === "other") return false;
|
||||
if (seenTypes.has(e.type)) return false;
|
||||
seenTypes.add(e.type);
|
||||
return true;
|
||||
});
|
||||
|
||||
/** Last directory segment from a pattern, used to disambiguate short-name collisions. */
|
||||
function patternToDirName(pattern) {
|
||||
const dir = patternToDir(pattern).replace(/\/$/, "");
|
||||
const parts = dir.split("/").filter(Boolean);
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
// Detect short-name collisions across tiers (e.g. lib/types vs shared/types).
|
||||
// Without disambiguation, a colliding node appears in multiple rank=same subgraphs,
|
||||
// which merges those tiers' ranks in Graphviz.
|
||||
const shortNameCounts = {};
|
||||
for (const el of graphElements) {
|
||||
if (el.type === "app/misc") continue;
|
||||
const n = getName(el.type);
|
||||
shortNameCounts[n] = (shortNameCounts[n] || 0) + 1;
|
||||
}
|
||||
|
||||
// Directory-name disambiguation can itself collide (shared/nav and app/nav
|
||||
// both live in a dir named "nav"), so later duplicates get tier-qualified.
|
||||
const elementNames = new Map();
|
||||
{
|
||||
const used = new Set();
|
||||
for (const el of graphElements) {
|
||||
if (el.type === "app/misc") continue;
|
||||
const base = getName(el.type);
|
||||
let name = shortNameCounts[base] > 1 ? patternToDirName(el.pattern) : base;
|
||||
if (used.has(name)) name = `${getTier(el.type)}/${name}`;
|
||||
used.add(name);
|
||||
elementNames.set(el.type, name);
|
||||
}
|
||||
}
|
||||
|
||||
function moduleNameForElement(el) {
|
||||
if (el.type === "app/misc") return "misc";
|
||||
return elementNames.get(el.type);
|
||||
}
|
||||
|
||||
/** Map collapsed directory paths → short module names, sorted longest-first for prefix matching.
|
||||
* Iterates ALL elements (not the per-type deduped graphElements) since a type
|
||||
* can own several patterns — a dir plus loose entry files. */
|
||||
const moduleNamesUnsorted = {};
|
||||
for (const el of elements) {
|
||||
if (el.type === "other" || el.type === "app/misc") continue;
|
||||
moduleNamesUnsorted[patternToDir(el.pattern)] = elementNames.get(el.type);
|
||||
}
|
||||
// Map individual app/misc file paths to "misc"
|
||||
for (const el of elements) {
|
||||
if (el.type === "app/misc" && el.mode === "full") {
|
||||
moduleNamesUnsorted[el.pattern] = "misc";
|
||||
}
|
||||
}
|
||||
const moduleNames = {};
|
||||
for (const key of Object.keys(moduleNamesUnsorted).sort((a, b) => b.length - a.length)) {
|
||||
moduleNames[key] = moduleNamesUnsorted[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual layout for the shared tier. Shared has too many modules to fit one
|
||||
* row, so we split it into named sub-tiers (each rendered as its own row, same
|
||||
* way `meta_feature` was extracted from `feature` for `enterprise`). All
|
||||
* sub-tiers share the "shared" color and legend entry.
|
||||
*
|
||||
* Any shared module not listed here falls into the last sub-tier.
|
||||
*/
|
||||
const sharedSubtiers = [
|
||||
// Top: embedding-SDK packaging
|
||||
{
|
||||
id: "shared_embedding",
|
||||
modules: [
|
||||
"custom-viz", "embedding", "embedding-ee", "embedding-sdk",
|
||||
"embedding-sdk-shared", "embedding-sdk-window-bridge",
|
||||
],
|
||||
},
|
||||
// Feature-adjacent shared modules (closest to feature tier)
|
||||
{
|
||||
id: "shared_apps",
|
||||
modules: [
|
||||
"detail-view", "metabot", "nav", "notifications", "monitor",
|
||||
"actions", "transforms", "comments", "content-translation",
|
||||
],
|
||||
},
|
||||
// Domain shared concepts — the core data/visualization primitives
|
||||
{
|
||||
id: "shared_domain",
|
||||
modules: [
|
||||
"common", "querying", "api", "visualizations", "visualizer",
|
||||
"parameters", "metadata", "palette", "static-viz",
|
||||
],
|
||||
},
|
||||
// Content / lifecycle pieces shared across features
|
||||
{
|
||||
id: "shared_content",
|
||||
modules: [
|
||||
"archive", "data-grid", "databases", "new", "pulse",
|
||||
"questions", "status", "timelines", "rich_text_editing", "schema",
|
||||
],
|
||||
},
|
||||
// Redux / routing / plugin wiring infrastructure
|
||||
{
|
||||
id: "shared_infra",
|
||||
modules: [
|
||||
"redux", "redux-store", "selectors", "plugins", "route-guards",
|
||||
"routes-stable-id-aware", "error-boundary", "cljs-dev-tools",
|
||||
],
|
||||
},
|
||||
// Bottom: pure utilities (closest to lib tier)
|
||||
{
|
||||
id: "shared_utils",
|
||||
modules: ["forms", "hoc", "hooks", "router", "styled-components", "types", "urls"],
|
||||
},
|
||||
];
|
||||
const SHARED_FALLBACK_SUBTIER = sharedSubtiers[sharedSubtiers.length - 1].id;
|
||||
|
||||
/**
|
||||
* Same row-splitting for the feature tier, which has grown too wide for one
|
||||
* row. Big product surfaces on top, smaller pages below (closest to shared).
|
||||
* Any feature module not listed falls into the last sub-tier.
|
||||
*/
|
||||
const featureSubtiers = [
|
||||
{
|
||||
id: "feature_core",
|
||||
modules: [
|
||||
"dashboard", "query_builder", "admin", "admin-theme-preview",
|
||||
"collections", "data-studio", "documents", "models", "public", "reference",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "feature_pages",
|
||||
modules: [
|
||||
"home", "account", "auth", "browse", "data-app-sandbox",
|
||||
"metrics", "metrics-viewer", "search", "setup",
|
||||
],
|
||||
},
|
||||
];
|
||||
const FEATURE_FALLBACK_SUBTIER = featureSubtiers[featureSubtiers.length - 1].id;
|
||||
|
||||
const moduleToSubtier = {};
|
||||
for (const st of [...sharedSubtiers, ...featureSubtiers]) {
|
||||
for (const m of st.modules) {
|
||||
moduleToSubtier[m] = st.id;
|
||||
}
|
||||
}
|
||||
|
||||
function tierForElement(el) {
|
||||
if (el.type === "feature/enterprise") return "meta_feature";
|
||||
const baseTier = getTier(el.type);
|
||||
if (baseTier === "shared") {
|
||||
return moduleToSubtier[moduleNameForElement(el)] ?? SHARED_FALLBACK_SUBTIER;
|
||||
}
|
||||
if (baseTier === "feature") {
|
||||
return moduleToSubtier[moduleNameForElement(el)] ?? FEATURE_FALLBACK_SUBTIER;
|
||||
}
|
||||
return baseTier;
|
||||
}
|
||||
|
||||
/** Tier ordering for the graph layout (app at top, lib at bottom). Fixed
|
||||
* rather than derived from element order, which shifts as modules move. */
|
||||
const tierOrder = ["app", "feature", "shared", "basic", "lib"].filter((t) =>
|
||||
graphElements.some((e) => getTier(e.type) === t),
|
||||
);
|
||||
|
||||
// Insert a synthetic "meta_feature" tier for enterprise, above "feature"
|
||||
const featureIdx = tierOrder.indexOf("feature");
|
||||
if (featureIdx >= 0) {
|
||||
tierOrder.splice(featureIdx, 0, "meta_feature");
|
||||
}
|
||||
|
||||
// Expand "feature" and "shared" into their sub-tiers
|
||||
const featureSplitIdx = tierOrder.indexOf("feature");
|
||||
if (featureSplitIdx >= 0) {
|
||||
tierOrder.splice(featureSplitIdx, 1, ...featureSubtiers.map((s) => s.id));
|
||||
}
|
||||
const sharedIdx = tierOrder.indexOf("shared");
|
||||
if (sharedIdx >= 0) {
|
||||
tierOrder.splice(sharedIdx, 1, ...sharedSubtiers.map((s) => s.id));
|
||||
}
|
||||
|
||||
// Shared-only mode drops every non-shared row
|
||||
if (SHARED_ONLY) {
|
||||
const sharedIds = new Set(sharedSubtiers.map((s) => s.id));
|
||||
for (let i = tierOrder.length - 1; i >= 0; i--) {
|
||||
if (!sharedIds.has(tierOrder[i])) tierOrder.splice(i, 1);
|
||||
}
|
||||
}
|
||||
const tierOrderSet = new Set(tierOrder);
|
||||
|
||||
/** Tier definitions: label, color, and module names */
|
||||
const tiers = {};
|
||||
const moduleToBaseTier = {};
|
||||
for (const el of graphElements) {
|
||||
const tier = tierForElement(el);
|
||||
const baseTier = getTier(el.type);
|
||||
moduleToBaseTier[moduleNameForElement(el)] = baseTier;
|
||||
if (!tierOrderSet.has(tier)) continue; // dropped row (shared-only mode)
|
||||
if (!tiers[tier]) {
|
||||
tiers[tier] = {
|
||||
label:
|
||||
SHARED_ONLY && tier.startsWith("shared_")
|
||||
? tier.slice("shared_".length)
|
||||
: tier === "meta_feature" ? "feature"
|
||||
: tier.startsWith("feature") ? "feature"
|
||||
: tier.startsWith("shared") ? "shared"
|
||||
: tier,
|
||||
color:
|
||||
SHARED_ONLY && sharedSubtierColors[tier]
|
||||
? sharedSubtierColors[tier].fill
|
||||
: tierColors[baseTier] || "#ffffff",
|
||||
modules: [],
|
||||
};
|
||||
}
|
||||
tiers[tier].modules.push(moduleNameForElement(el));
|
||||
}
|
||||
for (const el of elements) {
|
||||
if (el.type === "app/misc") moduleToBaseTier["misc"] = "app";
|
||||
}
|
||||
|
||||
// Catch-all bucket for files not claimed by any element (loose entry files,
|
||||
// leftovers in partially-claimed dirs). Rendered grey in the app row.
|
||||
if (!SHARED_ONLY) {
|
||||
tiers["app"].modules.push("other");
|
||||
moduleToBaseTier["other"] = "app";
|
||||
}
|
||||
|
||||
/** Directories already assigned to a tier */
|
||||
const namedDirs = new Set();
|
||||
for (const el of elements) {
|
||||
if (el.type === "other" || el.type === "app/misc") continue;
|
||||
const match = el.pattern.match(/^frontend\/src\/metabase\/([\w-]+)\//);
|
||||
if (match) namedDirs.add(match[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invisible vertical spines that anchor the horizontal column layout. Each
|
||||
* spine puts its modules in the same x-column. Avoid putting two modules from
|
||||
* the same tier in one spine — Graphviz's chain semantics would force them
|
||||
* onto different ranks, breaking the tier's rank=same row.
|
||||
*/
|
||||
// Spines act as invisible vertical column anchors. spine[0] is the central
|
||||
// column — pick one anchor module from each tier so every row has a node on
|
||||
// the central spine. Other modules in each row split half-and-half around it.
|
||||
const spines = [
|
||||
[
|
||||
"misc", // app
|
||||
"enterprise", // meta_feature
|
||||
"dashboard", // feature_core
|
||||
"home", // feature_pages
|
||||
"embedding", // shared_embedding
|
||||
"metabot", // shared_apps
|
||||
"common", // shared_domain
|
||||
"archive", // shared_content
|
||||
"redux", // shared_infra
|
||||
"urls", // shared_utils
|
||||
"ui", // basic
|
||||
"metabase-types", // lib
|
||||
],
|
||||
["querying", "utils"],
|
||||
["admin", "visualizations"],
|
||||
["reference"],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Run depcruise & parse JSON
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const moduleDirs = [
|
||||
...new Set(
|
||||
elements
|
||||
.filter((el) => el.type !== "app/misc" && el.type !== "other")
|
||||
// Shared-only mode needs only shared sources cruised — edges from
|
||||
// other tiers are dropped anyway.
|
||||
.filter((el) => !SHARED_ONLY || getTier(el.type) === "shared")
|
||||
.map((el) => patternToDir(el.pattern).replace(/\/$/, ""))
|
||||
.filter((dir) => !dir.includes("*") && existsSync(dir)),
|
||||
),
|
||||
];
|
||||
|
||||
/** Build collapse pattern from elements */
|
||||
function buildCollapsePattern() {
|
||||
// One literal prefix per module directory, sorted longest-first so nested
|
||||
// module dirs win the regex alternation over their parent (mlv1 inside
|
||||
// mlv2's dir, app/embedding-iframe-sdk inside shared/embedding's dir, ...).
|
||||
// Single-file elements (mode: "full") are skipped — depcruise reports them
|
||||
// uncollapsed and their exact path is already a moduleNames key. The
|
||||
// trailing generic pattern collapses not-yet-assigned dirs into per-dir
|
||||
// buckets that render as "other".
|
||||
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const filePaths = elements
|
||||
.filter((el) => el.mode === "full" && !el.pattern.includes("*"))
|
||||
.map((el) => el.pattern);
|
||||
const dirPrefixes = new Map();
|
||||
for (const el of elements) {
|
||||
if (el.type === "other" || el.type === "app/misc") continue;
|
||||
if (!el.pattern.includes("**")) continue;
|
||||
const dir = patternToDir(el.pattern);
|
||||
if (dirPrefixes.has(dir)) continue;
|
||||
// Single-file elements nested inside this dir (e.g. the window-bridge
|
||||
// file inside embedding-sdk-shared) must not be vacuumed into its bucket.
|
||||
const nestedFiles = filePaths
|
||||
.filter((p) => p !== dir && p.startsWith(dir))
|
||||
.map((p) => `${escapeRegex(p.slice(dir.length))}$`);
|
||||
const lookahead = nestedFiles.length > 0 ? `(?!${nestedFiles.join("|")})` : "";
|
||||
dirPrefixes.set(dir, `^${dir}${lookahead}`);
|
||||
}
|
||||
const parts = [...dirPrefixes.entries()]
|
||||
.sort(([a], [b]) => b.length - a.length)
|
||||
.map(([, regex]) => regex);
|
||||
parts.push("^frontend/src/metabase/([^/]+)/");
|
||||
return parts.join("|");
|
||||
}
|
||||
|
||||
const collapsePattern = buildCollapsePattern();
|
||||
|
||||
const depcruiseCmd = [
|
||||
"bunx depcruise",
|
||||
...moduleDirs,
|
||||
"--config .dependency-cruiser.mjs",
|
||||
"--output-type json",
|
||||
].join(" ");
|
||||
|
||||
// Helper: run depcruise via temp file to avoid ENOBUFS with large output
|
||||
function depcruiseJSON(extraArgs = "") {
|
||||
const tmp = join(tmpdir(), `depcruise-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
|
||||
try {
|
||||
execSync(`${depcruiseCmd} ${extraArgs} > "${tmp}"`, {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
});
|
||||
return JSON.parse(readFileSync(tmp, "utf8"));
|
||||
} finally {
|
||||
try { unlinkSync(tmp); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// Collapsed run for edges and violation detection
|
||||
const collapsed = depcruiseJSON(`--collapse '${collapsePattern}'`);
|
||||
|
||||
const moduleToTier = {};
|
||||
for (const [tier, { modules }] of Object.entries(tiers)) {
|
||||
for (const mod of modules) {
|
||||
moduleToTier[mod] = tier;
|
||||
}
|
||||
}
|
||||
|
||||
const tierIndex = {};
|
||||
tierOrder.forEach((t, i) => {
|
||||
tierIndex[t] = i;
|
||||
});
|
||||
|
||||
// Modules whose outgoing boundary rules are NOT enforced by the linter
|
||||
// (only counting modules that are actually displayed)
|
||||
const unenforcedModules = new Set(
|
||||
graphElements
|
||||
.filter((e) => !e.enforceOutgoing && tierOrderSet.has(tierForElement(e)))
|
||||
.map((e) => moduleNameForElement(e)),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Collect edges and violations from collapsed output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const edges = new Map();
|
||||
const inDegree = {};
|
||||
|
||||
for (const mod of collapsed.modules) {
|
||||
const fromName = moduleNames[mod.source] ?? (mod.source.startsWith("frontend/src/") ? "other" : null);
|
||||
if (!fromName) continue;
|
||||
|
||||
for (const dep of mod.dependencies) {
|
||||
const toName = moduleNames[dep.resolved] ?? (dep.resolved.startsWith("frontend/src/") ? "other" : null);
|
||||
if (!toName || toName === fromName) continue;
|
||||
|
||||
const key = `${fromName}->${toName}`;
|
||||
if (!edges.has(key)) {
|
||||
edges.set(key, {
|
||||
from: fromName,
|
||||
to: toName,
|
||||
violations: [],
|
||||
violationCount: 0,
|
||||
});
|
||||
inDegree[toName] = (inDegree[toName] ?? 0) + 1;
|
||||
}
|
||||
|
||||
const boundaryRules = (dep.rules ?? []).filter(
|
||||
(r) => r.name !== "no-circular",
|
||||
);
|
||||
if (boundaryRules.length > 0) {
|
||||
const edge = edges.get(key);
|
||||
edge.violationCount += 1;
|
||||
for (const rule of boundaryRules) {
|
||||
if (!edge.violations.includes(rule.name)) {
|
||||
edge.violations.push(rule.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build Graphviz dot source
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Whether buildDot emitted any red violation edge; postProcessSvg only shows
|
||||
// the violation legend entry when it did.
|
||||
let hasViolationEdges = false;
|
||||
|
||||
function buildDot() {
|
||||
const out = [];
|
||||
const emit = (line) => out.push(line);
|
||||
|
||||
// Only reference declared modules in spines — a chain mentioning a node
|
||||
// that no tier row declares (e.g. non-shared modules in shared-only mode)
|
||||
// would make Graphviz conjure a default node for it.
|
||||
const activeSpines = spines
|
||||
.map((s) => s.filter((m) => moduleToTier[m] !== undefined))
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
// spineColumn = which spine the module belongs to (anchors its x position).
|
||||
// spinePosition = index within that spine; tie-breaker so the in-row chain
|
||||
// runs in the same direction as the spine chain (otherwise we get invisible
|
||||
// cycles that blow up the x-layout).
|
||||
const spineColumn = {};
|
||||
const spinePosition = {};
|
||||
for (let i = 0; i < activeSpines.length; i++) {
|
||||
for (let j = 0; j < activeSpines[i].length; j++) {
|
||||
spineColumn[activeSpines[i][j]] = i;
|
||||
spinePosition[activeSpines[i][j]] = j;
|
||||
}
|
||||
}
|
||||
function spineSort(a, b) {
|
||||
const ca = spineColumn[a] ?? 999;
|
||||
const cb = spineColumn[b] ?? 999;
|
||||
if (ca !== cb) return ca - cb;
|
||||
return (spinePosition[a] ?? 999) - (spinePosition[b] ?? 999);
|
||||
}
|
||||
|
||||
// Graph attributes
|
||||
emit("digraph modules {");
|
||||
emit(' rankdir="TB"');
|
||||
emit(' newrank="true"');
|
||||
emit(' splines="line"');
|
||||
emit(' outputorder="edgesfirst"');
|
||||
emit(' nodesep="0.8"');
|
||||
emit(' ranksep="0.7"');
|
||||
emit(' pad="0.3"');
|
||||
emit(' fontname="Helvetica"');
|
||||
emit(
|
||||
' node [shape="box" style="rounded,filled" fontname="Helvetica" fontsize="12" height="0.4"]',
|
||||
);
|
||||
emit(' edge [fontname="Helvetica" fontsize="9" arrowsize="0.7"]');
|
||||
emit("");
|
||||
|
||||
// Legend swatches — declared as regular nodes so they integrate into each
|
||||
// tier row (one swatch per tier means the swatches line up vertically with
|
||||
// their corresponding row). To avoid showing "shared" five times across the
|
||||
// sub-tier rows, only the first sub-tier in each label-group gets a label;
|
||||
// the rest get a colored swatch with no text. The shared `group="legend"`
|
||||
// attribute keeps the swatches in a tidy vertical column.
|
||||
let prevLegendLabel = null;
|
||||
for (const tierId of tierOrder) {
|
||||
const { label, color } = tiers[tierId];
|
||||
const displayLabel = label === prevLegendLabel ? "" : label;
|
||||
prevLegendLabel = label;
|
||||
emit(
|
||||
` "legend_${tierId}" [label="${displayLabel}" fillcolor="${color}" width="1.0" group="legend"]`,
|
||||
);
|
||||
}
|
||||
emit("");
|
||||
|
||||
// Module nodes — unenforced modules get a bold border so it's visible
|
||||
// which modules still need their boundaries locked down.
|
||||
for (const tierId of tierOrder) {
|
||||
const { color, modules } = tiers[tierId];
|
||||
for (const mod of modules) {
|
||||
const extra = unenforcedModules.has(mod) ? ` penwidth="2"` : "";
|
||||
const fill = mod === "other" ? "#eeeeee" : color;
|
||||
emit(
|
||||
` "${mod}" [label="${mod}" fillcolor="${fill}" group="${tierId}"${extra}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
emit("");
|
||||
|
||||
// rank=same subgraphs — one row per tier. Each shared sub-tier is its own
|
||||
// tier, the same way meta_feature is. The legend swatch is the leftmost
|
||||
// node, then non-spine modules are distributed around spine[0] alternating
|
||||
// left and right, ordered by in-degree (most-imported closest to the spine).
|
||||
function emitTierSubgraph(tierId, indent) {
|
||||
const { modules } = tiers[tierId];
|
||||
const centralMods = modules
|
||||
.filter((m) => spineColumn[m] === 0)
|
||||
.sort(spineSort);
|
||||
// Non-spine[0] spine modules: split across both sides by spine column
|
||||
// parity. Putting all of them on one side caused 2+ imbalance in rows
|
||||
// like feature (admin + reference both ended up right of dashboard).
|
||||
const spineOthers = modules
|
||||
.filter((m) => spineColumn[m] !== undefined && spineColumn[m] !== 0)
|
||||
.sort((a, b) => spineColumn[a] - spineColumn[b]);
|
||||
const unspined = modules
|
||||
.filter((m) => spineColumn[m] === undefined)
|
||||
.sort((a, b) => (inDegree[b] ?? 0) - (inDegree[a] ?? 0));
|
||||
// Empirically, spine[1] and spine[2] columns end up to the right of
|
||||
// spine[0] in Graphviz's layout (they get anchored by their inter-row
|
||||
// chains). Putting their modules on the right side of the row chain keeps
|
||||
// the emitted order consistent with the rendered order. Then fill unspined
|
||||
// on whichever side has fewer modules so the row stays balanced.
|
||||
const leftInnerToOuter = [];
|
||||
const rightInnerToOuter = [...spineOthers];
|
||||
unspined.forEach((m) => {
|
||||
(leftInnerToOuter.length <= rightInnerToOuter.length
|
||||
? leftInnerToOuter
|
||||
: rightInnerToOuter
|
||||
).push(m);
|
||||
});
|
||||
const leftHalf = leftInnerToOuter.reverse(); // outermost first for chain order
|
||||
const rightHalf = rightInnerToOuter;
|
||||
const rowNodes = [
|
||||
`legend_${tierId}`,
|
||||
...leftHalf,
|
||||
...centralMods,
|
||||
...rightHalf,
|
||||
];
|
||||
emit(`${indent}subgraph cluster_tier_${tierId} {`);
|
||||
emit(`${indent} style="invis"`);
|
||||
emit(`${indent} rank="same"`);
|
||||
for (const mod of rowNodes) {
|
||||
emit(`${indent} "${mod}"`);
|
||||
}
|
||||
if (rowNodes.length >= 2) {
|
||||
const chain = rowNodes.map((m) => `"${m}"`).join(" -> ");
|
||||
emit(`${indent} ${chain} [style="invis" weight="1"]`);
|
||||
}
|
||||
emit(`${indent}}`);
|
||||
}
|
||||
|
||||
for (const tierId of tierOrder) {
|
||||
emitTierSubgraph(tierId, " ");
|
||||
emit("");
|
||||
}
|
||||
|
||||
// Vertical chain through the legend column so swatches stack top-to-bottom
|
||||
for (let i = 0; i < tierOrder.length - 1; i++) {
|
||||
emit(
|
||||
` "legend_${tierOrder[i]}" -> "legend_${tierOrder[i + 1]}" [style="invis" weight="100"]`,
|
||||
);
|
||||
}
|
||||
emit("");
|
||||
|
||||
// Invisible spines for horizontal column anchoring
|
||||
for (const spine of activeSpines) {
|
||||
for (let i = 0; i < spine.length - 1; i++) {
|
||||
emit(` "${spine[i]}" -> "${spine[i + 1]}" [style="invis" weight="100"]`);
|
||||
}
|
||||
}
|
||||
|
||||
// Between-tier stacking: spines only traverse tiers that contain a spine
|
||||
// module, so tiers like the shared sub-tiers (which often have no spine
|
||||
// module) need an explicit chain to sit between their neighbours.
|
||||
for (let t = 0; t < tierOrder.length - 1; t++) {
|
||||
const upperModules = tiers[tierOrder[t]].modules;
|
||||
const lowerModules = tiers[tierOrder[t + 1]].modules;
|
||||
if (!upperModules.length || !lowerModules.length) continue;
|
||||
const upper = [...upperModules].sort(spineSort)[0];
|
||||
const lower = [...lowerModules].sort(spineSort)[0];
|
||||
emit(` "${upper}" -> "${lower}" [style="invis" weight="1000"]`);
|
||||
}
|
||||
emit("");
|
||||
|
||||
// Dependency edges — only between declared nodes (shared-only mode drops
|
||||
// every edge with a non-shared endpoint).
|
||||
for (const { from, to, violations, violationCount } of edges.values()) {
|
||||
if (moduleToTier[from] === undefined || moduleToTier[to] === undefined) {
|
||||
continue;
|
||||
}
|
||||
const attrs = [];
|
||||
const boundaryViolations = violations.filter((v) => v !== "no-circular");
|
||||
|
||||
if (boundaryViolations.length > 0) {
|
||||
const tip = `${boundaryViolations.join(", ")} (${violationCount} violations)`;
|
||||
attrs.push('color="red"', 'penwidth="2.0"');
|
||||
attrs.push(`tooltip="${tip}"`, `edgetooltip="${tip}"`);
|
||||
hasViolationEdges = true;
|
||||
} else {
|
||||
// Edge stroke per EDGE_COLOR_MODE (configured at the top of this file).
|
||||
let stroke;
|
||||
if (EDGE_COLOR_MODE === "source" || EDGE_COLOR_MODE === "target") {
|
||||
const endpoint = EDGE_COLOR_MODE === "source" ? from : to;
|
||||
if (SHARED_ONLY) {
|
||||
const c = sharedSubtierColors[moduleToTier[endpoint]]?.edge ?? "#000000";
|
||||
stroke = `${c}${DEFAULT_EDGE_ALPHA}`;
|
||||
} else {
|
||||
const tier = moduleToBaseTier[endpoint];
|
||||
const c = tierEdgeColors[tier] ?? "#000000";
|
||||
const a = tierEdgeAlpha[tier] ?? DEFAULT_EDGE_ALPHA;
|
||||
stroke = `${c}${a}`;
|
||||
}
|
||||
} else {
|
||||
stroke = "#00000055";
|
||||
}
|
||||
attrs.push(`color="${stroke}"`, 'penwidth="1.0"');
|
||||
attrs.push(`tooltip="${from} → ${to}"`, `edgetooltip="${from} → ${to}"`);
|
||||
}
|
||||
attrs.push('constraint="false"');
|
||||
emit(` "${from}" -> "${to}" [${attrs.join(" ")}]`);
|
||||
}
|
||||
|
||||
emit("}");
|
||||
return out.join("\n") + "\n";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render dot → SVG
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderSvg(dotSource) {
|
||||
return execSync("dot -Tsvg", { input: dotSource, encoding: "utf8" });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post-process SVG: inject violation arrow in legend + "Other modules" sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function postProcessSvg(svg) {
|
||||
// Remove default graph title (shows "modules" tooltip on hover)
|
||||
svg = svg.replace(/<title>modules<\/title>\n?/, "");
|
||||
|
||||
const viewBoxMatch = svg.match(/viewBox="([^"]+)"/);
|
||||
if (!viewBoxMatch) return svg;
|
||||
|
||||
const [minX, minY, width, height] = viewBoxMatch[1].split(/\s+/).map(Number);
|
||||
|
||||
const injected = [];
|
||||
|
||||
// Graph polygon bounds (graphviz coords) — needed for legend overflow check
|
||||
// and "Other modules" sidebar placement.
|
||||
const polyMatch = svg.match(
|
||||
/id="graph0"[^>]*>[\s\S]*?<polygon[^>]*points="([\d.,-\s]+)"/,
|
||||
);
|
||||
let graphRight = 520;
|
||||
let graphTop = -420;
|
||||
let graphBottom = 20;
|
||||
if (polyMatch) {
|
||||
const pts = polyMatch[1].split(/\s+/).map((p) => p.split(",").map(Number));
|
||||
graphRight = Math.max(...pts.map(([x]) => x));
|
||||
graphTop = Math.min(...pts.map(([, y]) => y));
|
||||
graphBottom = Math.max(...pts.map(([, y]) => y));
|
||||
}
|
||||
|
||||
// --- Violation arrow in legend ---
|
||||
// Find the last tier legend node to position below it
|
||||
const lastLegendTier = (() => {
|
||||
// The legend collapses adjacent same-label tiers — walk tierOrder backwards
|
||||
// to find the last tier id that actually has a legend node.
|
||||
const seen = new Set();
|
||||
const order = [];
|
||||
for (const tierId of tierOrder) {
|
||||
const label = tiers[tierId].label;
|
||||
if (seen.has(label)) continue;
|
||||
seen.add(label);
|
||||
order.push(tierId);
|
||||
}
|
||||
return order[order.length - 1];
|
||||
})();
|
||||
// --- Legend rounded box outline + indicators ---
|
||||
// The legend swatches are emitted in the dot graph (one per tier row) so they
|
||||
// line up vertically with their tier. Here we add the rounded outline around
|
||||
// the whole legend column, plus the violation/unenforced indicators beneath
|
||||
// the swatches.
|
||||
function nodeBBox(title) {
|
||||
const m = svg.match(
|
||||
new RegExp(`<title>${title}</title>\\s*<path[^>]*d="([^"]+)"`),
|
||||
);
|
||||
if (!m) return null;
|
||||
const coords = [...m[1].matchAll(/(-?[\d.]+),(-?[\d.]+)/g)].map((c) => [
|
||||
parseFloat(c[1]),
|
||||
parseFloat(c[2]),
|
||||
]);
|
||||
if (!coords.length) return null;
|
||||
return {
|
||||
minX: Math.min(...coords.map(([x]) => x)),
|
||||
maxX: Math.max(...coords.map(([x]) => x)),
|
||||
minY: Math.min(...coords.map(([, y]) => y)),
|
||||
maxY: Math.max(...coords.map(([, y]) => y)),
|
||||
};
|
||||
}
|
||||
const lastBounds = nodeBBox(`legend_${lastLegendTier}`);
|
||||
const firstBounds = nodeBBox(`legend_${tierOrder[0]}`);
|
||||
let bottomExtra = 0;
|
||||
if (lastBounds && firstBounds) {
|
||||
const swatchCenterX = (lastBounds.minX + lastBounds.maxX) / 2;
|
||||
const swatchHalfH = (lastBounds.maxY - lastBounds.minY) / 2;
|
||||
const firstY = (firstBounds.minY + firstBounds.maxY) / 2;
|
||||
const legendLibY = (lastBounds.minY + lastBounds.maxY) / 2;
|
||||
|
||||
// Violation arrow indicator
|
||||
const arrowX1 = swatchCenterX - 20;
|
||||
const arrowX2 = swatchCenterX + 20;
|
||||
const arrowY = legendLibY + swatchHalfH + 28;
|
||||
const violationLabelY = arrowY + 14;
|
||||
// Indicators are only shown when the graph actually contains what they
|
||||
// explain: the red arrow when a violation edge was drawn, the bold-border
|
||||
// sample while any module still has enforceOutgoing: false.
|
||||
const showViolation = hasViolationEdges;
|
||||
const showUnenforced = unenforcedModules.size > 0;
|
||||
const sampleW = 40;
|
||||
const sampleH = 14;
|
||||
const sampleX = swatchCenterX - sampleW / 2;
|
||||
const sampleY = (showViolation ? violationLabelY : arrowY - 14) + 14;
|
||||
const unenforcedLabelY = sampleY + sampleH + 12;
|
||||
// Rounded box around the whole legend column
|
||||
const titleH = 22;
|
||||
const boxX = swatchCenterX - 50;
|
||||
const boxW = 100;
|
||||
const boxY = firstY - swatchHalfH - titleH - 6;
|
||||
const boxBottom =
|
||||
(showUnenforced ? unenforcedLabelY
|
||||
: showViolation ? violationLabelY
|
||||
: legendLibY + swatchHalfH) + 8;
|
||||
const boxH = boxBottom - boxY;
|
||||
|
||||
bottomExtra = Math.max(0, boxBottom - graphTop - height + minY + 24);
|
||||
|
||||
injected.push(
|
||||
`<g id="legend-box">`,
|
||||
` <rect x="${boxX}" y="${boxY}" width="${boxW}" height="${boxH}" rx="8" ry="8" fill="none" stroke="#cccccc" stroke-width="1"/>`,
|
||||
` <text x="${swatchCenterX}" y="${boxY + 16}" text-anchor="middle" font-family="Helvetica,sans-Serif" font-size="12" fill="#666666">Legend</text>`,
|
||||
`</g>`,
|
||||
);
|
||||
if (showViolation) {
|
||||
injected.push(
|
||||
`<g id="legend-violation">`,
|
||||
` <defs><marker id="arrowhead-red" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="red"/></marker></defs>`,
|
||||
` <line x1="${arrowX1}" y1="${arrowY}" x2="${arrowX2}" y2="${arrowY}" stroke="red" stroke-width="2" marker-end="url(#arrowhead-red)"/>`,
|
||||
` <text x="${swatchCenterX}" y="${violationLabelY}" text-anchor="middle" font-family="Helvetica,sans-Serif" font-size="10" fill="#888888">violation</text>`,
|
||||
`</g>`,
|
||||
);
|
||||
}
|
||||
if (showUnenforced) {
|
||||
injected.push(
|
||||
`<g id="legend-unenforced">`,
|
||||
` <rect x="${sampleX}" y="${sampleY}" width="${sampleW}" height="${sampleH}" rx="4" ry="4" fill="none" stroke="#333333" stroke-width="2"/>`,
|
||||
` <text x="${swatchCenterX}" y="${unenforcedLabelY}" text-anchor="middle" font-family="Helvetica,sans-Serif" font-size="10" fill="#888888">unenforced</text>`,
|
||||
`</g>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- "Other modules" sidebar (omitted when every dir is assigned, and in
|
||||
// shared-only mode where unassigned dirs are out of scope) ---
|
||||
const otherDirs = SHARED_ONLY
|
||||
? []
|
||||
: readdirSync("frontend/src/metabase", { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory() && !namedDirs.has(d.name))
|
||||
.map((d) => d.name)
|
||||
.sort();
|
||||
|
||||
const lineHeight = 16;
|
||||
const padding = 12;
|
||||
const colWidth = 140;
|
||||
const colGap = 8;
|
||||
const headerHeight = 28;
|
||||
const rows = Math.ceil(otherDirs.length / 2);
|
||||
const boxWidth = colWidth * 2 + colGap + padding * 2;
|
||||
const boxHeight = rows * lineHeight + headerHeight + padding * 2;
|
||||
const boxX = graphRight + 20;
|
||||
const boxY = graphTop + 30;
|
||||
|
||||
if (otherDirs.length > 0) {
|
||||
injected.push(
|
||||
`<g id="other-modules">`,
|
||||
` <rect x="${boxX}" y="${boxY}" width="${boxWidth}" height="${boxHeight}" rx="8" ry="8" fill="white" stroke="#cccccc" stroke-width="1"/>`,
|
||||
` <text x="${boxX + boxWidth / 2}" y="${boxY + 20}" text-anchor="middle" font-family="Helvetica,sans-Serif" font-size="12" fill="#666666">Other Modules</text>`,
|
||||
` <line x1="${boxX + padding}" y1="${boxY + headerHeight}" x2="${boxX + boxWidth - padding}" y2="${boxY + headerHeight}" stroke="#eeeeee" stroke-width="1"/>`,
|
||||
...otherDirs.map((dir, i) => {
|
||||
const col = i % 2;
|
||||
const row = Math.floor(i / 2);
|
||||
const x = boxX + padding + col * (colWidth + colGap);
|
||||
const y = boxY + headerHeight + padding + row * lineHeight + 12;
|
||||
return ` <text x="${x}" y="${y}" font-family="Helvetica,sans-Serif" font-size="10" fill="#888888">${dir}</text>`;
|
||||
}),
|
||||
`</g>`,
|
||||
);
|
||||
}
|
||||
|
||||
// Crop empty space on the left so the legend sits near the canvas edge,
|
||||
// and expand viewBox for the right-side sidebar + any legend indicator
|
||||
// overflow at the bottom.
|
||||
const legendBoxLeft = lastBounds
|
||||
? (lastBounds.minX + lastBounds.maxX) / 2 - 50
|
||||
: minX;
|
||||
const newMinX = Math.max(minX, legendBoxLeft - 10);
|
||||
const sidebarWidth = otherDirs.length > 0 ? boxWidth + 60 : 0;
|
||||
const newWidth = width - (newMinX - minX) + sidebarWidth;
|
||||
const newHeight =
|
||||
Math.max(height, otherDirs.length > 0 ? boxHeight + 40 : 0) + bottomExtra;
|
||||
|
||||
svg = svg.replace(
|
||||
/viewBox="[^"]+"/,
|
||||
`viewBox="${newMinX} ${minY} ${newWidth} ${newHeight}"`,
|
||||
);
|
||||
svg = svg.replace(/\bwidth="[\d.]+pt"/, `width="${newWidth}pt"`);
|
||||
svg = svg.replace(/\bheight="[\d.]+pt"/, `height="${newHeight}pt"`);
|
||||
// Inject inside the graph transform group so coordinates match
|
||||
svg = svg.replace("</g>\n</svg>", injected.join("\n") + "\n</g>\n</svg>");
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const dot = buildDot();
|
||||
const svg = postProcessSvg(renderSvg(dot));
|
||||
process.stdout.write(svg);
|
||||
Reference in New Issue
Block a user