Skip to content

Commit bd18df9

Browse files
authored
Merge pull request #2831 from hey-api/refactor/graph-declarations
refactor: add walk function instead of hard-coded logic
2 parents b2ab11a + edd6a97 commit bd18df9

File tree

15 files changed

+688
-671
lines changed

15 files changed

+688
-671
lines changed

packages/openapi-ts-tests/main/test/openapi-ts.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,11 @@ export default defineConfig(() => {
4040
// 'dutchie.json',
4141
// 'invalid',
4242
// 'openai.yaml',
43-
// 'full.yaml',
43+
'full.yaml',
4444
// 'opencode.yaml',
4545
// 'sdk-instance.yaml',
4646
// 'string-with-format.yaml',
47-
'transformers.json',
47+
// 'transformers.json',
4848
// 'type-format.yaml',
4949
// 'validators.yaml',
5050
// 'validators-circular-ref.json',

packages/openapi-ts/src/debug/graph.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Graph } from '~/openApi/shared/utils/graph';
1+
import type { Graph } from '~/graph';
22

33
const analyzeStructure = (graph: Graph) => {
44
let maxDepth = 0;
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import type { Graph } from '~/graph';
4+
import {
5+
getIrPointerPriority,
6+
matchIrPointerToGroup,
7+
preferGroups,
8+
} from '~/ir/graph';
9+
import { buildGraph } from '~/openApi/shared/utils/graph';
10+
11+
import { walk } from '../walk';
12+
13+
const loggerStub = {
14+
timeEvent: () => ({ timeEnd: () => {} }),
15+
} as any;
16+
17+
describe('walkTopological', () => {
18+
const makeGraph = (
19+
deps: Array<[string, Array<string>]>,
20+
nodes: Array<string>,
21+
) => {
22+
const nodeDependencies = new Map<string, Set<string>>();
23+
const subtreeDependencies = new Map<string, Set<string>>();
24+
const reverseNodeDependencies = new Map<string, Set<string>>();
25+
const nodesMap = new Map<string, any>();
26+
27+
for (const name of nodes) {
28+
nodesMap.set(name, { key: null, node: {}, parentPointer: null });
29+
}
30+
31+
for (const [from, toList] of deps) {
32+
const s = new Set<string>(toList);
33+
nodeDependencies.set(from, s);
34+
subtreeDependencies.set(from, new Set<string>(toList));
35+
for (const to of toList) {
36+
if (!reverseNodeDependencies.has(to))
37+
reverseNodeDependencies.set(to, new Set());
38+
reverseNodeDependencies.get(to)!.add(from);
39+
}
40+
}
41+
42+
return {
43+
nodeDependencies,
44+
nodes: nodesMap,
45+
reverseNodeDependencies,
46+
subtreeDependencies,
47+
transitiveDependencies: new Map<string, Set<string>>(),
48+
} as unknown as Graph;
49+
};
50+
51+
it('walks nodes in topological order for a simple acyclic graph', () => {
52+
// Graph: A -> B -> C
53+
const graph = makeGraph(
54+
[
55+
['A', ['B']],
56+
['B', ['C']],
57+
],
58+
['A', 'B', 'C'],
59+
);
60+
const order: Array<string> = [];
61+
walk(graph, (pointer) => order.push(pointer), { order: 'topological' });
62+
expect(order.indexOf('C')).toBeLessThan(order.indexOf('B'));
63+
expect(order.indexOf('B')).toBeLessThan(order.indexOf('A'));
64+
expect(order).toHaveLength(3);
65+
});
66+
67+
it('walks nodes in topological order for multiple roots', () => {
68+
// Graph: A -> B, C -> D
69+
const graph = makeGraph(
70+
[
71+
['A', ['B']],
72+
['C', ['D']],
73+
],
74+
['A', 'B', 'C', 'D'],
75+
);
76+
const order: Array<string> = [];
77+
walk(graph, (pointer) => order.push(pointer), { order: 'topological' });
78+
expect(order.indexOf('B')).toBeLessThan(order.indexOf('A'));
79+
expect(order.indexOf('D')).toBeLessThan(order.indexOf('C'));
80+
expect(order).toHaveLength(4);
81+
});
82+
83+
it('walks nodes in topological order for a disconnected graph', () => {
84+
// Graph: A -> B, C (no deps), D (no deps)
85+
const graph = makeGraph([['A', ['B']]], ['A', 'B', 'C', 'D']);
86+
const order: Array<string> = [];
87+
walk(graph, (pointer) => order.push(pointer), { order: 'topological' });
88+
expect(order.indexOf('B')).toBeLessThan(order.indexOf('A'));
89+
expect(order).toHaveLength(4);
90+
expect(order).toContain('C');
91+
expect(order).toContain('D');
92+
});
93+
94+
it('walks nodes in topological order for a diamond dependency', () => {
95+
// Graph: A
96+
// / \
97+
// B C
98+
// \ /
99+
// D
100+
const graph = makeGraph(
101+
[
102+
['A', ['B', 'C']],
103+
['B', ['D']],
104+
['C', ['D']],
105+
],
106+
['A', 'B', 'C', 'D'],
107+
);
108+
const order: Array<string> = [];
109+
walk(graph, (pointer) => order.push(pointer), { order: 'topological' });
110+
expect(order.indexOf('D')).toBeLessThan(order.indexOf('B'));
111+
expect(order.indexOf('D')).toBeLessThan(order.indexOf('C'));
112+
expect(order.indexOf('B')).toBeLessThan(order.indexOf('A'));
113+
expect(order.indexOf('C')).toBeLessThan(order.indexOf('A'));
114+
expect(order).toHaveLength(4);
115+
});
116+
117+
it('walks nodes in topological order for a long chain', () => {
118+
// Graph: A -> B -> C -> D -> E
119+
const graph = makeGraph(
120+
[
121+
['A', ['B']],
122+
['B', ['C']],
123+
['C', ['D']],
124+
['D', ['E']],
125+
],
126+
['A', 'B', 'C', 'D', 'E'],
127+
);
128+
const order: Array<string> = [];
129+
walk(graph, (pointer) => order.push(pointer), { order: 'topological' });
130+
expect(order.indexOf('E')).toBeLessThan(order.indexOf('D'));
131+
expect(order.indexOf('D')).toBeLessThan(order.indexOf('C'));
132+
expect(order.indexOf('C')).toBeLessThan(order.indexOf('B'));
133+
expect(order.indexOf('B')).toBeLessThan(order.indexOf('A'));
134+
expect(order).toHaveLength(5);
135+
});
136+
137+
it('walks all nodes, including cycles', () => {
138+
// Graph: A <-> B (cycle), C (no deps)
139+
const graph = makeGraph(
140+
[
141+
['A', ['B']],
142+
['B', ['A']],
143+
],
144+
['A', 'B', 'C'],
145+
);
146+
const order: Array<string> = [];
147+
walk(graph, (pointer) => order.push(pointer), { order: 'topological' });
148+
expect(order.sort()).toEqual(['A', 'B', 'C']);
149+
});
150+
151+
it('matches ordering for validators-circular-ref spec', async () => {
152+
const specModule = await import(
153+
'../../../../../specs/3.1.x/validators-circular-ref.json'
154+
);
155+
const spec = specModule.default ?? specModule;
156+
const { graph } = buildGraph(spec, loggerStub);
157+
158+
const order: Array<string> = [];
159+
walk(graph, (pointer) => order.push(pointer), { order: 'topological' });
160+
161+
const foo = '#/components/schemas/Foo';
162+
const bar = '#/components/schemas/Bar';
163+
const baz = '#/components/schemas/Baz';
164+
const qux = '#/components/schemas/Qux';
165+
166+
// Bar should come before Foo because Foo depends on Bar
167+
expect(order.indexOf(bar)).toBeLessThan(order.indexOf(foo));
168+
169+
// Baz and Qux form a mutual $ref cycle; both must be present
170+
expect(order).toContain(baz);
171+
expect(order).toContain(qux);
172+
});
173+
174+
it('prefers schema group before parameter when safe (default)', () => {
175+
// parameter then schema in declaration order, no deps -> schema should move before parameter
176+
const param = '#/components/parameters/P';
177+
const schema = '#/components/schemas/A';
178+
const nodes = [param, schema];
179+
const graph = makeGraph([], nodes);
180+
181+
const order: Array<string> = [];
182+
walk(graph, (pointer) => order.push(pointer), {
183+
getPointerPriority: getIrPointerPriority,
184+
matchPointerToGroup: matchIrPointerToGroup,
185+
order: 'topological',
186+
preferGroups,
187+
});
188+
expect(order.indexOf(schema)).toBeLessThan(order.indexOf(param));
189+
});
190+
191+
it('does not apply preferGroups when it would violate dependencies (fallback)', () => {
192+
// declaration order: param, schema; schema depends on param -> cannot move before param
193+
const param = '#/components/parameters/P';
194+
const schema = '#/components/schemas/S';
195+
const nodes = [param, schema];
196+
const nodeDependencies = new Map<string, Set<string>>();
197+
nodeDependencies.set(schema, new Set([param]));
198+
const subtreeDependencies = new Map<string, Set<string>>();
199+
const reverseNodeDependencies = new Map<string, Set<string>>();
200+
const nodesMap = new Map<string, any>();
201+
for (const n of nodes)
202+
nodesMap.set(n, { key: null, node: {}, parentPointer: null });
203+
const graph = {
204+
nodeDependencies,
205+
nodes: nodesMap,
206+
reverseNodeDependencies,
207+
subtreeDependencies,
208+
transitiveDependencies: new Map<string, Set<string>>(),
209+
} as unknown as Graph;
210+
211+
const order: Array<string> = [];
212+
walk(graph, (pointer) => order.push(pointer), { order: 'topological' });
213+
// schema depends on param so param must remain before schema
214+
expect(order.indexOf(param)).toBeLessThan(order.indexOf(schema));
215+
});
216+
217+
it('ignores self-dependencies when ordering', () => {
218+
// Foo has self-ref only, Bar references Foo -> Foo should come before Bar
219+
const foo = '#/components/schemas/Foo';
220+
const bar = '#/components/schemas/Bar';
221+
const nodes = [foo, bar];
222+
const nodeDependencies = new Map<string, Set<string>>();
223+
nodeDependencies.set(foo, new Set([foo]));
224+
nodeDependencies.set(bar, new Set([foo]));
225+
226+
const nodesMap = new Map<string, any>();
227+
for (const n of nodes)
228+
nodesMap.set(n, { key: null, node: {}, parentPointer: null });
229+
230+
const graph = {
231+
nodeDependencies,
232+
nodes: nodesMap,
233+
reverseNodeDependencies: new Map<string, Set<string>>(),
234+
subtreeDependencies: new Map<string, Set<string>>(),
235+
transitiveDependencies: new Map<string, Set<string>>(),
236+
} as unknown as Graph;
237+
238+
const order: Array<string> = [];
239+
walk(graph, (pointer) => order.push(pointer), { order: 'topological' });
240+
// Foo is a dependency of Bar, so Foo should come before Bar
241+
expect(order.indexOf(foo)).toBeLessThan(order.indexOf(bar));
242+
});
243+
244+
it('uses subtreeDependencies when nodeDependencies are absent', () => {
245+
const parent = '#/components/schemas/Parent';
246+
const child = '#/components/schemas/Child';
247+
const nodes = [parent, child];
248+
const nodeDependencies = new Map<string, Set<string>>();
249+
const subtreeDependencies = new Map<string, Set<string>>();
250+
subtreeDependencies.set(parent, new Set([child]));
251+
252+
const nodesMap = new Map<string, any>();
253+
for (const n of nodes)
254+
nodesMap.set(n, { key: null, node: {}, parentPointer: null });
255+
256+
const graph = {
257+
nodeDependencies,
258+
nodes: nodesMap,
259+
reverseNodeDependencies: new Map<string, Set<string>>(),
260+
subtreeDependencies,
261+
transitiveDependencies: new Map<string, Set<string>>(),
262+
} as unknown as Graph;
263+
264+
const order: Array<string> = [];
265+
walk(graph, (pointer) => order.push(pointer), { order: 'topological' });
266+
expect(order.indexOf(child)).toBeLessThan(order.indexOf(parent));
267+
});
268+
269+
it('preserves declaration order for equal-priority items (stability)', () => {
270+
const a = '#/components/schemas/A';
271+
const b = '#/components/schemas/B';
272+
const c = '#/components/schemas/C';
273+
const nodes = [a, b, c];
274+
const graph = makeGraph([], nodes);
275+
const order: Array<string> = [];
276+
walk(graph, (pointer) => order.push(pointer), { order: 'topological' });
277+
expect(order).toEqual(nodes);
278+
});
279+
280+
it('walks nodes in declaration order when order=declarations', () => {
281+
const a = '#/components/schemas/A';
282+
const b = '#/components/schemas/B';
283+
const c = '#/components/schemas/C';
284+
const nodes = [a, b, c];
285+
const graph = makeGraph([], nodes);
286+
const order: Array<string> = [];
287+
walk(graph, (pointer) => order.push(pointer), { order: 'declarations' });
288+
expect(order).toEqual(nodes);
289+
});
290+
291+
it('applies preferGroups ordering in declaration mode', () => {
292+
const param = '#/components/parameters/P';
293+
const schema = '#/components/schemas/A';
294+
const nodes = [param, schema];
295+
const graph = makeGraph([], nodes);
296+
297+
const order: Array<string> = [];
298+
walk(graph, (pointer) => order.push(pointer), {
299+
matchPointerToGroup: matchIrPointerToGroup,
300+
order: 'declarations',
301+
preferGroups,
302+
});
303+
// preferGroups puts schema before parameter
304+
expect(order.indexOf(schema)).toBeLessThan(order.indexOf(param));
305+
});
306+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export type { Graph, NodeInfo } from './types/graph';
2+
export type {
3+
GetPointerPriorityFn,
4+
PointerGroupMatch,
5+
WalkOptions,
6+
} from './types/walk';
7+
export { walk } from './walk';
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* The main graph structure for OpenAPI node analysis.
3+
*
4+
* @property nodeDependencies - For each node with at least one dependency, the set of normalized JSON Pointers it references via $ref. Nodes with no dependencies are omitted.
5+
* @property nodes - Map from normalized JSON Pointer to NodeInfo for every node in the spec.
6+
* @property reverseNodeDependencies - For each node with at least one dependent, the set of nodes that reference it via $ref. Nodes with no dependents are omitted.
7+
*/
8+
export type Graph = {
9+
/**
10+
* For each node with at least one dependency, the set of normalized JSON Pointers it references via $ref.
11+
* Nodes with no dependencies are omitted from this map.
12+
*/
13+
nodeDependencies: Map<string, Set<string>>;
14+
/**
15+
* Map from normalized JSON Pointer to NodeInfo for every node in the spec.
16+
*/
17+
nodes: Map<string, NodeInfo>;
18+
/**
19+
* For each node with at least one dependent, the set of nodes that reference it via $ref.
20+
* Nodes with no dependents are omitted from this map.
21+
*/
22+
reverseNodeDependencies: Map<string, Set<string>>;
23+
/**
24+
* For each node, the set of direct $ref targets that appear anywhere inside the node's
25+
* subtree (the node itself and its children). This is populated during graph construction
26+
* and is used to compute top-level dependency relationships where $ref may be attached to
27+
* child pointers instead of the parent.
28+
*/
29+
subtreeDependencies: Map<string, Set<string>>;
30+
/**
31+
* For each node, the set of all (transitive) normalized JSON Pointers it references via $ref anywhere in its subtree.
32+
* This includes both direct and indirect dependencies, making it useful for filtering, codegen, and tree-shaking.
33+
*/
34+
transitiveDependencies: Map<string, Set<string>>;
35+
};
36+
37+
/**
38+
* Information about a node in the OpenAPI graph.
39+
*
40+
* @property deprecated - Whether the node is deprecated. Optional.
41+
* @property key - The property name or array index in the parent, or null for root.
42+
* @property node - The actual object at this pointer in the spec.
43+
* @property parentPointer - The JSON Pointer of the parent node, or null for root.
44+
* @property scopes - The set of access scopes for this node, if any. Optional.
45+
* @property tags - The set of tags for this node, if any. Optional.
46+
*/
47+
export type NodeInfo = {
48+
/** Whether the node is deprecated. Optional. */
49+
deprecated?: boolean;
50+
/** The property name or array index in the parent, or null for root. */
51+
key: string | number | null;
52+
/** The actual object at this pointer in the spec. */
53+
node: unknown;
54+
/** The JSON Pointer of the parent node, or null for root. */
55+
parentPointer: string | null;
56+
/** The set of access scopes for this node, if any. Optional. */
57+
scopes?: Set<Scope>;
58+
/** The set of tags for this node, if any. Optional. */
59+
tags?: Set<string>;
60+
};

0 commit comments

Comments
 (0)