From 73e3c60570f8133a4ef7b55d83b86d1e99a9983c Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 16:15:38 -0500 Subject: [PATCH 1/2] fix: make component dependency resolution cycle safe --- .../project/buildComponentDependencyList.ts | 71 ++++++++++++++----- 1 file changed, 54 insertions(+), 17 deletions(-) diff --git a/src/util/project/buildComponentDependencyList.ts b/src/util/project/buildComponentDependencyList.ts index 6e48d31..eeed2c2 100644 --- a/src/util/project/buildComponentDependencyList.ts +++ b/src/util/project/buildComponentDependencyList.ts @@ -3,23 +3,60 @@ import type { Components } from '@emulsify-cli/config'; export default function buildComponentDependencyList( components: Components, name: string, -) { - const rootComponent = components.filter( - (component) => component.name == name, - ); - if (rootComponent.length == 0) return []; - let finalList = [name]; - const list = rootComponent[0].dependency as string[]; - if (list && list.length > 0) { - list.forEach((componentName: string) => { - finalList = [ - ...new Set( - finalList.concat( - buildComponentDependencyList(components, componentName), - ), - ), - ]; - }); +): string[] { + const componentsByName = new Map(); + for (const component of components) { + if (!componentsByName.has(component.name)) { + componentsByName.set(component.name, component); + } } + + if (!componentsByName.has(name)) return []; + + const finalList: string[] = []; + const visited = new Set(); + const stack: string[] = []; + + function visit(componentName: string, referencedBy?: string): void { + const cycleStart = stack.indexOf(componentName); + if (cycleStart !== -1) { + const cyclePath = [...stack.slice(cycleStart), componentName].join( + ' -> ', + ); + + throw new Error( + `Circular component dependency detected while resolving "${name}": ${cyclePath}.`, + ); + } + + if (visited.has(componentName)) { + return; + } + + const component = componentsByName.get(componentName); + if (!component) { + const dependencyPath = [...stack, componentName].join(' -> '); + const referencedByMessage = referencedBy + ? ` referenced by "${referencedBy}"` + : ''; + + throw new Error( + `Cannot resolve component dependency "${componentName}"${referencedByMessage} while resolving "${name}". Dependency path: ${dependencyPath}.`, + ); + } + + stack.push(componentName); + visited.add(componentName); + finalList.push(componentName); + + for (const dependencyName of component.dependency ?? []) { + visit(dependencyName, componentName); + } + + stack.pop(); + } + + visit(name); + return finalList; } From 9f45d7e489eeb608ac5b195ee445f639435c1a03 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 16:16:30 -0500 Subject: [PATCH 2/2] test: cover component dependency graph errors --- .../buildComponentDependencyList.test.ts | 89 +++++++++++++------ 1 file changed, 62 insertions(+), 27 deletions(-) diff --git a/src/util/project/buildComponentDependencyList.test.ts b/src/util/project/buildComponentDependencyList.test.ts index d6790f9..ede48fb 100644 --- a/src/util/project/buildComponentDependencyList.test.ts +++ b/src/util/project/buildComponentDependencyList.test.ts @@ -4,63 +4,98 @@ import buildComponentDependencyList from './buildComponentDependencyList.js'; describe('buildComponentDependencyList', () => { const components = [ { - name: 'buttons', + name: 'button', structure: 'base', - dependency: [], + dependency: ['icon'], }, { - name: 'images', + name: 'icon', structure: 'base', - dependency: [], }, { - name: 'links', + name: 'card', structure: 'base', - dependency: [], + dependency: ['teaser'], }, { - name: 'text', + name: 'teaser', structure: 'base', - dependency: ['links'], + dependency: ['image'], }, { - name: 'card', + name: 'image', structure: 'base', - dependency: ['images', 'text', 'links', 'buttons'], }, { - name: 'menus', + name: 'gallery', structure: 'molecules', - dependency: ['images', 'text'], + dependency: ['teaser', 'image'], }, ] as Components; - it('Build list of components without dependency', () => { - expect(buildComponentDependencyList(components, 'buttons')).toEqual([ - 'buttons', + it('returns the root component followed by direct dependencies', () => { + expect(buildComponentDependencyList(components, 'button')).toEqual([ + 'button', + 'icon', ]); }); - it('Build all components dependency for not existing component', () => { + it('returns an empty dependency list for a missing root component', () => { expect(buildComponentDependencyList(components, 'test')).toEqual([]); }); - it('Build all components dependency tree returning flat list without duplicates', () => { + it('returns nested dependencies in deterministic preorder', () => { expect(buildComponentDependencyList(components, 'card')).toEqual([ 'card', - 'images', - 'text', - 'links', - 'buttons', + 'teaser', + 'image', ]); }); - it('Build all components dependency tree with hierarchical dependency', () => { - expect(buildComponentDependencyList(components, 'menus')).toEqual([ - 'menus', - 'images', - 'text', - 'links', + it('returns duplicate nested dependencies only once', () => { + expect(buildComponentDependencyList(components, 'gallery')).toEqual([ + 'gallery', + 'teaser', + 'image', ]); }); + + it('throws a clear error when a dependency is missing', () => { + expect(() => + buildComponentDependencyList( + [ + { + name: 'button', + structure: 'base', + dependency: ['missing'], + }, + ] as Components, + 'button', + ), + ).toThrow( + 'Cannot resolve component dependency "missing" referenced by "button" while resolving "button". Dependency path: button -> missing.', + ); + }); + + it('throws a clear error when dependencies are circular', () => { + expect(() => + buildComponentDependencyList( + [ + { + name: 'a', + structure: 'base', + dependency: ['b'], + }, + { + name: 'b', + structure: 'base', + dependency: ['a'], + }, + ] as Components, + 'a', + ), + ).toThrow( + 'Circular component dependency detected while resolving "a": a -> b -> a.', + ); + }); });