Compare commits

...

10 Commits

Author SHA1 Message Date
Fraser
14c1028a6c Add --shared-only mode: shared tier internals only
bun run module-graph-shared renders just the six shared sub-tier rows and
the edges between shared modules into module-boundaries-shared.svg. Each
sub-tier gets its own fill/edge color and legend label (one amber for
everything reads as a hairball at this zoom). Legend indicators now only
appear when the graph contains what they explain; the full graph output is
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 12:54:36 +12:00
Fraser
362f7d0de4 Split feature tier into two rows
Feature has grown to ~20 modules — too wide for one row. Mirror the
shared sub-tier mechanism: big product surfaces on top, smaller pages
below, sharing the feature color and legend entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 12:45:51 +12:00
Fraser
22c138ed1b Update module graph for current module layout
- Rebuild sharedSubtiers/spines for the post-promotion module set (many
  shared modules are now features; shared split into 6 rows)
- Fix name collisions (shared/nav vs app/nav) and multi-pattern types
  (mcp-app owns a dir plus loose entry files)
- Derive collapse pattern from every element dir, longest-first, so nested
  module dirs (embedding-iframe-sdk inside embedding, mlv1 inside
  metabase-lib) collapse to the right node
- dep-cruiser rules: pathNot for nested foreign modules, per-allow-set rule
  grouping (fixes false reds on whitelisted edges like admin-theme-preview),
  includeOnly covers hyphenated/nested/file-based elements
- Fixed tier row order (was derived from element order), render the 'other'
  bucket as a grey app-row node, hide empty sidebar/unenforced legend

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 12:10:40 +12:00
Fraser
c459a25473 Merge remote-tracking branch 'origin/master' into visualize-module-graph
# Conflicts:
#	bun.lock
#	eslint.config.module-boundaries.mjs
#	package.json
2026-07-30 11:36:47 +12:00
Fraser
0a7448e829 Update 2026-05-21 12:19:05 +12:00
Fraser
3f955088b7 Updates 2026-05-21 12:19:05 +12:00
Fraser
5debb11004 Highlight unenforced modules 2026-05-21 12:19:05 +12:00
Fraser
728e0a22ea Tidies 2026-05-21 12:19:05 +12:00
Fraser
acc45003a1 Rebase and fix viz 2026-05-21 12:19:05 +12:00
Fraser
8e10249d6f Visualize module graph 2026-05-21 12:19:05 +12:00
9 changed files with 16215 additions and 0 deletions

562
.dependency-cruiser.mjs Normal file
View 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" },
},
],
},
},
},
},
};

View 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"],
},
},
};

View File

@@ -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

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 200 KiB

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
View 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 | |

View File

@@ -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}",

View 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
View 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);