* Returns the cycle as an ordered list of group ids, or null if acyclic. Edges * are induced by columns: an edge A → B exists iff B depends on a column that * A produces.
(groups: WorkflowGroup[])
| 927 | * A produces. |
| 928 | */ |
| 929 | function findGroupCycle(groups: WorkflowGroup[]): string[] | null { |
| 930 | // Map each output column → the group that produces it. |
| 931 | const producerByColumn = new Map<string, string>() |
| 932 | for (const g of groups) { |
| 933 | for (const o of g.outputs) producerByColumn.set(o.columnName, g.id) |
| 934 | } |
| 935 | const adjacency = new Map<string, string[]>() |
| 936 | for (const g of groups) { |
| 937 | const upstream = new Set<string>() |
| 938 | for (const depCol of g.dependencies?.columns ?? []) { |
| 939 | const producer = producerByColumn.get(depCol) |
| 940 | if (producer && producer !== g.id) upstream.add(producer) |
| 941 | } |
| 942 | adjacency.set(g.id, [...upstream]) |
| 943 | } |
| 944 | const VISITING = 1 |
| 945 | const VISITED = 2 |
| 946 | const state = new Map<string, number>() |
| 947 | const stack: string[] = [] |
| 948 | |
| 949 | const dfs = (id: string): string[] | null => { |
| 950 | if (state.get(id) === VISITED) return null |
| 951 | if (state.get(id) === VISITING) { |
| 952 | const cycleStart = stack.indexOf(id) |
| 953 | return cycleStart >= 0 ? [...stack.slice(cycleStart), id] : [id] |
| 954 | } |
| 955 | state.set(id, VISITING) |
| 956 | stack.push(id) |
| 957 | for (const next of adjacency.get(id) ?? []) { |
| 958 | const found = dfs(next) |
| 959 | if (found) return found |
| 960 | } |
| 961 | stack.pop() |
| 962 | state.set(id, VISITED) |
| 963 | return null |
| 964 | } |
| 965 | |
| 966 | for (const g of groups) { |
| 967 | const cycle = dfs(g.id) |
| 968 | if (cycle) return cycle |
| 969 | } |
| 970 | return null |
| 971 | } |
| 972 | |
| 973 | interface SplitGroupReport { |
| 974 | groupId: string |
no test coverage detected