@webpieces/nx-webpieces-rules 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +373 -0
  2. package/executors.json +124 -0
  3. package/package.json +36 -0
  4. package/src/executor-result.ts +7 -0
  5. package/src/executors/generate/executor.ts +61 -0
  6. package/src/executors/generate/schema.json +14 -0
  7. package/src/executors/help/executor.ts +63 -0
  8. package/src/executors/help/schema.json +7 -0
  9. package/src/executors/validate-architecture-unchanged/executor.ts +253 -0
  10. package/src/executors/validate-architecture-unchanged/schema.json +14 -0
  11. package/src/executors/validate-catch-error-pattern/executor.ts +11 -0
  12. package/src/executors/validate-catch-error-pattern/schema.json +24 -0
  13. package/src/executors/validate-code/executor.ts +11 -0
  14. package/src/executors/validate-code/schema.json +287 -0
  15. package/src/executors/validate-dtos/executor.ts +11 -0
  16. package/src/executors/validate-dtos/schema.json +33 -0
  17. package/src/executors/validate-eslint-sync/executor.ts +87 -0
  18. package/src/executors/validate-eslint-sync/schema.json +7 -0
  19. package/src/executors/validate-modified-files/executor.ts +11 -0
  20. package/src/executors/validate-modified-files/schema.json +25 -0
  21. package/src/executors/validate-modified-methods/executor.ts +11 -0
  22. package/src/executors/validate-modified-methods/schema.json +25 -0
  23. package/src/executors/validate-new-methods/executor.ts +11 -0
  24. package/src/executors/validate-new-methods/schema.json +25 -0
  25. package/src/executors/validate-no-any-unknown/executor.ts +11 -0
  26. package/src/executors/validate-no-any-unknown/schema.json +24 -0
  27. package/src/executors/validate-no-architecture-cycles/executor.ts +63 -0
  28. package/src/executors/validate-no-architecture-cycles/schema.json +8 -0
  29. package/src/executors/validate-no-destructure/executor.ts +11 -0
  30. package/src/executors/validate-no-destructure/schema.json +24 -0
  31. package/src/executors/validate-no-direct-api-resolver/executor.ts +11 -0
  32. package/src/executors/validate-no-direct-api-resolver/schema.json +29 -0
  33. package/src/executors/validate-no-implicit-any/executor.ts +11 -0
  34. package/src/executors/validate-no-implicit-any/schema.json +24 -0
  35. package/src/executors/validate-no-inline-types/executor.ts +11 -0
  36. package/src/executors/validate-no-inline-types/schema.json +24 -0
  37. package/src/executors/validate-no-skiplevel-deps/executor.ts +274 -0
  38. package/src/executors/validate-no-skiplevel-deps/schema.json +8 -0
  39. package/src/executors/validate-no-unmanaged-exceptions/executor.ts +11 -0
  40. package/src/executors/validate-no-unmanaged-exceptions/schema.json +24 -0
  41. package/src/executors/validate-packagejson/executor.ts +76 -0
  42. package/src/executors/validate-packagejson/schema.json +8 -0
  43. package/src/executors/validate-prisma-converters/executor.ts +11 -0
  44. package/src/executors/validate-prisma-converters/schema.json +38 -0
  45. package/src/executors/validate-return-types/executor.ts +11 -0
  46. package/src/executors/validate-return-types/schema.json +24 -0
  47. package/src/executors/validate-ts-in-src/executor.ts +283 -0
  48. package/src/executors/validate-ts-in-src/schema.json +25 -0
  49. package/src/executors/validate-versions-locked/executor.ts +376 -0
  50. package/src/executors/validate-versions-locked/schema.json +8 -0
  51. package/src/executors/visualize/executor.ts +65 -0
  52. package/src/executors/visualize/schema.json +14 -0
  53. package/src/index.ts +9 -0
  54. package/src/lib/graph-comparator.ts +154 -0
  55. package/src/lib/graph-generator.ts +97 -0
  56. package/src/lib/graph-loader.ts +119 -0
  57. package/src/lib/graph-sorter.ts +137 -0
  58. package/src/lib/graph-visualizer.ts +253 -0
  59. package/src/lib/package-validator.ts +184 -0
  60. package/src/plugin.ts +666 -0
  61. package/src/toError.ts +36 -0
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Visualize Executor
3
+ *
4
+ * Generates visual representations of the architecture graph (DOT + HTML)
5
+ * and opens the visualization in a browser.
6
+ *
7
+ * Usage:
8
+ * nx run architecture:visualize
9
+ */
10
+
11
+ import type { ExecutorContext } from '@nx/devkit';
12
+ import { loadBlessedGraph } from '../../lib/graph-loader';
13
+ import { writeVisualization, openVisualization } from '../../lib/graph-visualizer';
14
+ import { toError } from '../../toError';
15
+
16
+ export interface VisualizeExecutorOptions {
17
+ graphPath?: string;
18
+ }
19
+
20
+ export interface ExecutorResult {
21
+ success: boolean;
22
+ }
23
+
24
+ export default async function runExecutor(
25
+ options: VisualizeExecutorOptions,
26
+ context: ExecutorContext
27
+ ): Promise<ExecutorResult> {
28
+ const graphPath = options.graphPath;
29
+ const workspaceRoot = context.root;
30
+
31
+ console.log('\n🎨 Architecture Visualization\n');
32
+
33
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
34
+ try {
35
+ // Load the saved graph
36
+ console.log('📂 Loading saved graph...');
37
+ const graph = loadBlessedGraph(workspaceRoot, graphPath);
38
+
39
+ if (!graph) {
40
+ console.error('❌ No saved graph found at architecture/dependencies.json');
41
+ console.error(' Run: nx run architecture:generate first');
42
+ return { success: false };
43
+ }
44
+
45
+ // Generate visualization
46
+ console.log('🎨 Generating visualization...');
47
+ const vizPaths = writeVisualization(graph, workspaceRoot);
48
+ console.log(`✅ Generated: ${vizPaths.dotPath}`);
49
+ console.log(`✅ Generated: ${vizPaths.htmlPath}`);
50
+
51
+ // Try to open in browser
52
+ console.log('\n🌐 Opening visualization in browser...');
53
+ if (openVisualization(vizPaths.htmlPath)) {
54
+ console.log('✅ Browser opened');
55
+ } else {
56
+ console.log(`⚠️ Could not auto-open. Open manually: ${vizPaths.htmlPath}`);
57
+ }
58
+
59
+ return { success: true };
60
+ } catch (err: unknown) {
61
+ const error = toError(err);
62
+ console.error('❌ Visualization failed:', error.message);
63
+ return { success: false };
64
+ }
65
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "http://json-schema.org/schema",
3
+ "title": "Visualize Executor",
4
+ "description": "Generates visual representations of the architecture graph",
5
+ "type": "object",
6
+ "properties": {
7
+ "graphPath": {
8
+ "type": "string",
9
+ "description": "Path to the graph file (relative to workspace root)",
10
+ "default": "architecture/dependencies.json"
11
+ }
12
+ },
13
+ "required": []
14
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export * from './lib/graph-generator';
2
+ export * from './lib/graph-sorter';
3
+ export * from './lib/graph-comparator';
4
+ export * from './lib/package-validator';
5
+ export * from './lib/graph-loader';
6
+ export * from './lib/graph-visualizer';
7
+ import { createNodesV2 } from './plugin';
8
+ export { createNodesV2 };
9
+ export default { name: '@webpieces/nx-webpieces-rules', createNodesV2 };
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Graph Comparator
3
+ *
4
+ * Compares the current generated graph with the saved (blessed) graph.
5
+ * Used in validate mode to ensure developers have updated the graph file.
6
+ */
7
+
8
+ import type { EnhancedGraph } from './graph-sorter';
9
+
10
+ /**
11
+ * Difference between two graphs
12
+ */
13
+ export interface GraphDiff {
14
+ added: string[];
15
+ removed: string[];
16
+ modified: {
17
+ project: string;
18
+ addedDeps: string[];
19
+ removedDeps: string[];
20
+ levelChanged: { from: number; to: number } | null;
21
+ }[];
22
+ }
23
+
24
+ /**
25
+ * Comparison result
26
+ */
27
+ export interface ComparisonResult {
28
+ identical: boolean;
29
+ diff: GraphDiff;
30
+ summary: string;
31
+ }
32
+
33
+ /**
34
+ * Compare two graphs and return the differences
35
+ *
36
+ * @param current - Currently generated graph
37
+ * @param saved - Previously saved (blessed) graph
38
+ * @returns Comparison result with detailed diff
39
+ */
40
+ export function compareGraphs(current: EnhancedGraph, saved: EnhancedGraph): ComparisonResult {
41
+ const currentProjects = new Set(Object.keys(current));
42
+ const savedProjects = new Set(Object.keys(saved));
43
+
44
+ const diff: GraphDiff = {
45
+ added: [],
46
+ removed: [],
47
+ modified: [],
48
+ };
49
+
50
+ // Find added projects
51
+ for (const project of currentProjects) {
52
+ if (!savedProjects.has(project)) {
53
+ diff.added.push(project);
54
+ }
55
+ }
56
+
57
+ // Find removed projects
58
+ for (const project of savedProjects) {
59
+ if (!currentProjects.has(project)) {
60
+ diff.removed.push(project);
61
+ }
62
+ }
63
+
64
+ // Find modified projects
65
+ findModifiedProjects(current, saved, currentProjects, savedProjects, diff);
66
+
67
+ const identical =
68
+ diff.added.length === 0 && diff.removed.length === 0 && diff.modified.length === 0;
69
+
70
+ const summary = identical ? 'Graphs are identical' : buildSummary(diff);
71
+
72
+ return {
73
+ identical,
74
+ diff,
75
+ summary,
76
+ };
77
+ }
78
+
79
+ function findModifiedProjects(
80
+ current: EnhancedGraph,
81
+ saved: EnhancedGraph,
82
+ currentProjects: Set<string>,
83
+ savedProjects: Set<string>,
84
+ diff: GraphDiff
85
+ ): void {
86
+ for (const project of currentProjects) {
87
+ if (!savedProjects.has(project)) continue;
88
+
89
+ const currentEntry = current[project];
90
+ const savedEntry = saved[project];
91
+
92
+ const currentDeps = new Set(currentEntry.dependsOn);
93
+ const savedDeps = new Set(savedEntry.dependsOn);
94
+
95
+ const addedDeps: string[] = [];
96
+ const removedDeps: string[] = [];
97
+
98
+ for (const dep of currentDeps) {
99
+ if (!savedDeps.has(dep)) {
100
+ addedDeps.push(dep);
101
+ }
102
+ }
103
+
104
+ for (const dep of savedDeps) {
105
+ if (!currentDeps.has(dep)) {
106
+ removedDeps.push(dep);
107
+ }
108
+ }
109
+
110
+ const levelChanged =
111
+ currentEntry.level !== savedEntry.level
112
+ ? { from: savedEntry.level, to: currentEntry.level }
113
+ : null;
114
+
115
+ if (addedDeps.length > 0 || removedDeps.length > 0 || levelChanged) {
116
+ diff.modified.push({
117
+ project,
118
+ addedDeps,
119
+ removedDeps,
120
+ levelChanged,
121
+ });
122
+ }
123
+ }
124
+ }
125
+
126
+ function buildSummary(diff: GraphDiff): string {
127
+ const summaryParts: string[] = [];
128
+
129
+ if (diff.added.length > 0) {
130
+ summaryParts.push(`Added projects: ${diff.added.join(', ')}`);
131
+ }
132
+
133
+ if (diff.removed.length > 0) {
134
+ summaryParts.push(`Removed projects: ${diff.removed.join(', ')}`);
135
+ }
136
+
137
+ for (const mod of diff.modified) {
138
+ const parts: string[] = [];
139
+ if (mod.addedDeps.length > 0) {
140
+ parts.push(`+deps: ${mod.addedDeps.join(', ')}`);
141
+ }
142
+ if (mod.removedDeps.length > 0) {
143
+ parts.push(`-deps: ${mod.removedDeps.join(', ')}`);
144
+ }
145
+ if (mod.levelChanged) {
146
+ parts.push(`level: ${mod.levelChanged.from} -> ${mod.levelChanged.to}`);
147
+ }
148
+ if (parts.length > 0) {
149
+ summaryParts.push(`${mod.project}: ${parts.join('; ')}`);
150
+ }
151
+ }
152
+
153
+ return summaryParts.join('\n');
154
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Graph Generator
3
+ *
4
+ * Generates dependency graph from project.json files in the workspace.
5
+ * Reads build.dependsOn and implicitDependencies to determine project relationships.
6
+ */
7
+
8
+ import {
9
+ createProjectGraphAsync,
10
+ readProjectsConfigurationFromProjectGraph,
11
+ ProjectConfiguration,
12
+ } from '@nx/devkit';
13
+
14
+ /**
15
+ * Projects to exclude from graph validation (tools, configs, etc.)
16
+ */
17
+ const EXCLUDED_PROJECTS = new Set<string>([]);
18
+
19
+ /**
20
+ * Extract project dependencies from project.json's build.dependsOn and implicitDependencies
21
+ */
22
+ function extractBuildDependencies(projectConfig: ProjectConfiguration): string[] {
23
+ const deps: string[] = [];
24
+
25
+ // 1. Read from build.dependsOn
26
+ const buildTarget = projectConfig.targets?.['build'];
27
+ if (buildTarget && buildTarget.dependsOn) {
28
+ for (const dep of buildTarget.dependsOn) {
29
+ if (typeof dep === 'string') {
30
+ // Format: "project-name:build" or just "build" (for self)
31
+ const match = dep.match(/^([^:]+):build$/);
32
+ if (match) {
33
+ deps.push(match[1]);
34
+ }
35
+ }
36
+ }
37
+ }
38
+
39
+ // 2. Also read from implicitDependencies
40
+ if (projectConfig.implicitDependencies && Array.isArray(projectConfig.implicitDependencies)) {
41
+ for (const dep of projectConfig.implicitDependencies) {
42
+ if (typeof dep === 'string' && !deps.includes(dep)) {
43
+ deps.push(dep);
44
+ }
45
+ }
46
+ }
47
+
48
+ return deps.sort();
49
+ }
50
+
51
+ /**
52
+ * Generate raw dependency graph from project.json files
53
+ * Returns: { projectName: [dependencyNames] }
54
+ */
55
+ export async function generateRawGraph(): Promise<Record<string, string[]>> {
56
+ const projectGraph = await createProjectGraphAsync();
57
+ const projectsConfig = readProjectsConfigurationFromProjectGraph(projectGraph);
58
+ const rawDeps: Record<string, string[]> = {};
59
+
60
+ for (const [projectName, projectConfig] of Object.entries(projectsConfig.projects)) {
61
+ // Skip excluded projects (tools, plugins)
62
+ if (EXCLUDED_PROJECTS.has(projectName)) {
63
+ continue;
64
+ }
65
+
66
+ // Extract dependencies from build.dependsOn in project.json
67
+ const deps = extractBuildDependencies(projectConfig);
68
+ rawDeps[projectName] = deps;
69
+ }
70
+
71
+ return rawDeps;
72
+ }
73
+
74
+ /**
75
+ * Transform project names (sorting dependencies only - no scope transformation)
76
+ */
77
+ export function transformGraph(rawGraph: Record<string, string[]>): Record<string, string[]> {
78
+ const result: Record<string, string[]> = {};
79
+
80
+ for (const [projectName, deps] of Object.entries(rawGraph)) {
81
+ // Use project names as-is - don't force @webpieces scope on client projects
82
+ const transformedName = projectName;
83
+ const transformedDeps = deps.sort();
84
+
85
+ result[transformedName] = transformedDeps;
86
+ }
87
+
88
+ return result;
89
+ }
90
+
91
+ /**
92
+ * Generate complete dependency graph with transformations
93
+ */
94
+ export async function generateGraph(): Promise<Record<string, string[]>> {
95
+ const rawGraph = await generateRawGraph();
96
+ return transformGraph(rawGraph);
97
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Graph Loader
3
+ *
4
+ * Handles loading and saving the blessed dependency graph file.
5
+ * The graph is stored at architecture/dependencies.json in the workspace root.
6
+ */
7
+
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import type { EnhancedGraph } from './graph-sorter';
11
+ import { toError } from '../toError';
12
+
13
+ /**
14
+ * Default path for the dependencies file (relative to workspace root)
15
+ */
16
+ export const DEFAULT_GRAPH_PATH = 'architecture/dependencies.json';
17
+
18
+ /**
19
+ * Load the blessed graph from disk
20
+ *
21
+ * @param workspaceRoot - Absolute path to workspace root
22
+ * @param graphPath - Relative path to graph file (default: .graphs/dependencies.json)
23
+ * @returns The blessed graph, or null if file doesn't exist
24
+ */
25
+ export function loadBlessedGraph(
26
+ workspaceRoot: string,
27
+ graphPath: string = DEFAULT_GRAPH_PATH
28
+ ): EnhancedGraph | null {
29
+ const fullPath = path.join(workspaceRoot, graphPath);
30
+
31
+ if (!fs.existsSync(fullPath)) {
32
+ return null;
33
+ }
34
+
35
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
36
+ try {
37
+ const content = fs.readFileSync(fullPath, 'utf-8');
38
+ return JSON.parse(content) as EnhancedGraph;
39
+ } catch (err: unknown) {
40
+ const error = toError(err);
41
+ throw new Error(`Failed to load graph from ${fullPath}: ${error.message}`);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Format a graph as JSON with multi-line arrays for readability
47
+ */
48
+ function formatGraphJson(graph: EnhancedGraph): string {
49
+ const lines: string[] = ['{'];
50
+ const keys = Object.keys(graph).sort();
51
+
52
+ keys.forEach((key, index) => {
53
+ const entry = graph[key];
54
+ const isLast = index === keys.length - 1;
55
+ const comma = isLast ? '' : ',';
56
+
57
+ lines.push(` "${key}": {`);
58
+ lines.push(` "level": ${entry.level},`);
59
+
60
+ if (entry.dependsOn.length === 0) {
61
+ lines.push(` "dependsOn": []`);
62
+ } else {
63
+ lines.push(` "dependsOn": [`);
64
+ entry.dependsOn.forEach((dep, depIndex) => {
65
+ const depComma = depIndex === entry.dependsOn.length - 1 ? '' : ',';
66
+ lines.push(` "${dep}"${depComma}`);
67
+ });
68
+ lines.push(` ]`);
69
+ }
70
+
71
+ lines.push(` }${comma}`);
72
+
73
+ });
74
+
75
+ lines.push('}');
76
+ return lines.join('\n') + '\n';
77
+ }
78
+
79
+ /**
80
+ * Save the graph to disk
81
+ *
82
+ * @param graph - The graph to save
83
+ * @param workspaceRoot - Absolute path to workspace root
84
+ * @param graphPath - Relative path to graph file (default: .graphs/dependencies.json)
85
+ */
86
+ export function saveGraph(
87
+ graph: EnhancedGraph,
88
+ workspaceRoot: string,
89
+ graphPath: string = DEFAULT_GRAPH_PATH
90
+ ): void {
91
+ const fullPath = path.join(workspaceRoot, graphPath);
92
+ const dir = path.dirname(fullPath);
93
+
94
+ // Ensure directory exists
95
+ if (!fs.existsSync(dir)) {
96
+ fs.mkdirSync(dir, { recursive: true });
97
+ }
98
+
99
+ // Sort keys for deterministic output
100
+ const sortedGraph: EnhancedGraph = {};
101
+ const sortedKeys = Object.keys(graph).sort();
102
+ for (const key of sortedKeys) {
103
+ sortedGraph[key] = graph[key];
104
+ }
105
+
106
+ const content = formatGraphJson(sortedGraph);
107
+ fs.writeFileSync(fullPath, content, 'utf-8');
108
+ }
109
+
110
+ /**
111
+ * Check if the graph file exists
112
+ */
113
+ export function graphFileExists(
114
+ workspaceRoot: string,
115
+ graphPath: string = DEFAULT_GRAPH_PATH
116
+ ): boolean {
117
+ const fullPath = path.join(workspaceRoot, graphPath);
118
+ return fs.existsSync(fullPath);
119
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Graph Sorter
3
+ *
4
+ * Performs topological sorting on the dependency graph to:
5
+ * 1. Detect circular dependencies (fails if cycle found)
6
+ * 2. Assign level numbers to each project (level 0 = no deps, level 1 = depends on level 0, etc.)
7
+ * 3. Group projects into layers for deterministic ordering
8
+ */
9
+
10
+ /**
11
+ * Graph entry with level metadata
12
+ */
13
+ export interface GraphEntry {
14
+ level: number;
15
+ dependsOn: string[];
16
+ }
17
+
18
+ /**
19
+ * Enhanced graph format with level information
20
+ */
21
+ export type EnhancedGraph = Record<string, GraphEntry>;
22
+
23
+ /**
24
+ * Compute topological layers for dependency graph using Kahn's algorithm
25
+ *
26
+ * Projects are grouped into layers where each layer only depends on previous layers.
27
+ * Throws an error if a circular dependency is detected.
28
+ *
29
+ * @param graph - Dependency graph { project: [deps] }
30
+ * @returns Array of layers, each containing sorted project names
31
+ */
32
+ export function computeTopologicalLayers(graph: Record<string, string[]>): string[][] {
33
+ const layers: string[][] = [];
34
+ const processed = new Set<string>();
35
+ const allProjects = Object.keys(graph);
36
+
37
+ while (processed.size < allProjects.length) {
38
+ const currentLayer: string[] = [];
39
+
40
+ for (const project of allProjects) {
41
+ if (processed.has(project)) continue;
42
+
43
+ const deps = graph[project] || [];
44
+ // Check if all dependencies are in previous layers (already processed)
45
+ const allDepsInPrevLayers = deps.every((dep) => processed.has(dep));
46
+
47
+ if (allDepsInPrevLayers) {
48
+ currentLayer.push(project);
49
+ }
50
+ }
51
+
52
+ if (currentLayer.length === 0) {
53
+ // No progress made = circular dependency detected
54
+ const remaining = allProjects.filter((p) => !processed.has(p));
55
+
56
+ // Try to identify the cycle
57
+ const cycleInfo = findCycle(graph, remaining);
58
+
59
+ throw new Error(
60
+ `Circular dependency detected among: ${remaining.join(', ')}\n` +
61
+ (cycleInfo ? `Cycle: ${cycleInfo}\n` : '') +
62
+ 'Fix: Remove one of the dependencies to break the cycle.'
63
+ );
64
+ }
65
+
66
+ // Sort alphabetically within layer for deterministic output
67
+ currentLayer.sort();
68
+ layers.push(currentLayer);
69
+
70
+ // Mark as processed
71
+ currentLayer.forEach((p) => processed.add(p));
72
+ }
73
+
74
+ return layers;
75
+ }
76
+
77
+ /**
78
+ * Try to find and describe a cycle in the graph
79
+ */
80
+ function findCycle(graph: Record<string, string[]>, remaining: string[]): string | null {
81
+ const visited = new Set<string>();
82
+ const path: string[] = [];
83
+
84
+ function dfs(node: string): string | null {
85
+ if (path.includes(node)) {
86
+ const cycleStart = path.indexOf(node);
87
+ return [...path.slice(cycleStart), node].join(' -> ');
88
+ }
89
+ if (visited.has(node)) return null;
90
+
91
+ visited.add(node);
92
+ path.push(node);
93
+
94
+ const deps = graph[node] || [];
95
+ for (const dep of deps) {
96
+ if (remaining.includes(dep)) {
97
+ const result = dfs(dep);
98
+ if (result) return result;
99
+ }
100
+ }
101
+
102
+ path.pop();
103
+ return null;
104
+ }
105
+
106
+ for (const node of remaining) {
107
+ const cycle = dfs(node);
108
+ if (cycle) return cycle;
109
+ }
110
+
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * Sort graph in topological order with alphabetical sorting within layers
116
+ * Returns enhanced format with level metadata
117
+ *
118
+ * @param graph - Unsorted dependency graph { project: [deps] }
119
+ * @returns Sorted graph with level metadata { project: { level: number, dependsOn: [deps] } }
120
+ */
121
+ export function sortGraphTopologically(graph: Record<string, string[]>): EnhancedGraph {
122
+ const layers = computeTopologicalLayers(graph);
123
+ const result: EnhancedGraph = {};
124
+
125
+ // Add projects layer by layer (dependencies before dependents)
126
+ layers.forEach((layer, levelIndex) => {
127
+ for (const project of layer) {
128
+ // Already sorted alphabetically within layer
129
+ result[project] = {
130
+ level: levelIndex,
131
+ dependsOn: (graph[project] || []).sort(),
132
+ };
133
+ }
134
+ });
135
+
136
+ return result;
137
+ }