diff --git a/src/graph/builders/explicit/traverse_note.ts b/src/graph/builders/explicit/traverse_note.ts index 6f2d731f..a5db4de5 100644 --- a/src/graph/builders/explicit/traverse_note.ts +++ b/src/graph/builders/explicit/traverse_note.ts @@ -24,7 +24,8 @@ function dfs_edges( if (visited.has(item.node)) continue; visited.add(item.node); if (item.parent !== null) edges.push([item.parent, item.node]); - for (const child of Object.keys(links[item.node] ?? {})) { + // Push in reverse so the stack pops children in link order (#790). + for (const child of Object.keys(links[item.node] ?? {}).reverse()) { if (!visited.has(child)) { stack.push({ node: child, parent: item.node }); } diff --git a/tests/graph/builders/traverse_note.test.ts b/tests/graph/builders/traverse_note.test.ts index e55e541e..9b9688bb 100644 --- a/tests/graph/builders/traverse_note.test.ts +++ b/tests/graph/builders/traverse_note.test.ts @@ -37,6 +37,14 @@ describe("traverse_note builder", () => { t.expect(r.edges.every((e) => e.edge_type === "up")).toBe(true); }); + test("children are emitted in link order (#790)", async (t) => { + const r = await _add_explicit_edges_traverse_note( + plugin({ "A.md": { "B.md": 1, "C.md": 1, "D.md": 1 } }), + make_all_files([mock_file("A.md", { frontmatter: FIELD })]), + ); + t.expect(r.edges.map((e) => e.target)).toEqual(["B.md", "C.md", "D.md"]); + }); + test("cycles are not revisited", async (t) => { const r = await _add_explicit_edges_traverse_note( plugin({ "A.md": { "B.md": 1 }, "B.md": { "A.md": 1 } }), diff --git a/wasm/pkg/breadcrumbs_graph_wasm.d.ts b/wasm/pkg/breadcrumbs_graph_wasm.d.ts index 1c0e02f1..690fa5c9 100644 --- a/wasm/pkg/breadcrumbs_graph_wasm.d.ts +++ b/wasm/pkg/breadcrumbs_graph_wasm.d.ts @@ -235,7 +235,8 @@ export class NoteGraph { */ get_filtered_grouped_outgoing_edges(node: string, edge_types?: string[] | null): GroupedEdgeList; /** - * Get all outgoing edges from a node, filtered by edge type. + * Get all outgoing edges from a node, filtered by edge type, in insertion + * (document) order. */ get_filtered_outgoing_edges(node: string, edge_types?: string[] | null): EdgeList; /** @@ -244,7 +245,7 @@ export class NoteGraph { get_incoming_edges(node: string): EdgeList; get_node(node: string): NodeData | undefined; /** - * Get all outgoing edges from a node. + * Get all outgoing edges from a node, in insertion (document) order. */ get_outgoing_edges(node: string): EdgeList; /** diff --git a/wasm/pkg/breadcrumbs_graph_wasm.js b/wasm/pkg/breadcrumbs_graph_wasm.js index 09b71f62..247288ae 100644 --- a/wasm/pkg/breadcrumbs_graph_wasm.js +++ b/wasm/pkg/breadcrumbs_graph_wasm.js @@ -1604,7 +1604,8 @@ export class NoteGraph { return GroupedEdgeList.__wrap(ret); } /** - * Get all outgoing edges from a node, filtered by edge type. + * Get all outgoing edges from a node, filtered by edge type, in insertion + * (document) order. * @param {string} node * @param {string[] | null} [edge_types] * @returns {EdgeList} @@ -1639,7 +1640,7 @@ export class NoteGraph { return ret === 0 ? undefined : NodeData.__wrap(ret); } /** - * Get all outgoing edges from a node. + * Get all outgoing edges from a node, in insertion (document) order. * @param {string} node * @returns {EdgeList} */ diff --git a/wasm/pkg/breadcrumbs_graph_wasm_bg.wasm b/wasm/pkg/breadcrumbs_graph_wasm_bg.wasm index 050c3942..18011eb1 100644 Binary files a/wasm/pkg/breadcrumbs_graph_wasm_bg.wasm and b/wasm/pkg/breadcrumbs_graph_wasm_bg.wasm differ diff --git a/wasm/src/graph.rs b/wasm/src/graph.rs index 9093e3d2..7c202442 100644 --- a/wasm/src/graph.rs +++ b/wasm/src/graph.rs @@ -185,20 +185,13 @@ impl NoteGraph { }); } - /// Get all outgoing edges from a node. + /// Get all outgoing edges from a node, in insertion (document) order. pub fn get_outgoing_edges(&self, node: String) -> EdgeList { - let node_index = self.int_get_node_index(&node); - - EdgeList::from_vec(match node_index { - Some(node_index) => self - .int_iter_outgoing_edges(node_index) - .map(|edge| EdgeStruct::from_edge_ref(edge, self)) - .collect(), - None => Vec::new(), - }) + self.get_filtered_outgoing_edges(node, None) } - /// Get all outgoing edges from a node, filtered by edge type. + /// Get all outgoing edges from a node, filtered by edge type, in insertion + /// (document) order. pub fn get_filtered_outgoing_edges( &self, node: String, @@ -207,13 +200,20 @@ impl NoteGraph { let node_index = self.int_get_node_index(&node); EdgeList::from_vec(match node_index { - Some(node_index) => self - .int_iter_outgoing_edges(node_index) - .filter(|edge_ref| { - edge_matches_edge_filter_string(edge_ref.weight(), edge_types.as_ref()) - }) - .map(|edge| EdgeStruct::from_edge_ref(edge, self)) - .collect(), + Some(node_index) => { + let mut edges: Vec = self + .int_iter_outgoing_edges(node_index) + .filter(|edge_ref| { + edge_matches_edge_filter_string(edge_ref.weight(), edge_types.as_ref()) + }) + .map(|edge| EdgeStruct::from_edge_ref(edge, self)) + .collect(); + // petgraph prepends each new edge to the node's adjacency list, so + // iteration is newest-first. Reverse to restore insertion order, + // which is the order the builders read fields from the note (#790). + edges.reverse(); + edges + } None => Vec::new(), }) } diff --git a/wasm/tests/queries.rs b/wasm/tests/queries.rs index ad3730f7..10e1e9f6 100644 --- a/wasm/tests/queries.rs +++ b/wasm/tests/queries.rs @@ -270,6 +270,53 @@ fn test_get_filtered_outgoing_edges_selects_one_of_two_types() { assert_eq!(down_edges.edges.len(), 1); } +// ---- outgoing edge order (#790) ---- + +fn targets(g: &NoteGraph, edges: &[breadcrumbs_graph_wasm::data::edge_struct::EdgeStruct]) -> Vec { + edges.iter().map(|e| e.target_path(g).unwrap()).collect() +} + +/// `2026-W38` from #790: two `next` fields, in that order in the note. +fn week_graph() -> NoteGraph { + let mut g = NoteGraph::new(); + g.build_graph( + vec![node("W38"), node("2026-09"), node("W37"), node("W39"), node("My weeks")], + vec![ + edge("W38", "2026-09", "up"), + edge("W38", "W37", "prev"), + edge("W38", "W39", "next"), + edge("W38", "My weeks", "next"), + ], + vec![], + ) + .unwrap(); + g +} + +#[wasm_bindgen_test] +fn test_get_outgoing_edges_keeps_insertion_order() { + let g = week_graph(); + let edges = g.get_outgoing_edges("W38".to_owned()).edges; + assert_eq!(targets(&g, &edges), vec!["2026-09", "W37", "W39", "My weeks"]); +} + +#[wasm_bindgen_test] +fn test_get_filtered_outgoing_edges_keeps_insertion_order() { + let g = week_graph(); + let edges = g + .get_filtered_outgoing_edges("W38".to_owned(), Some(vec!["next".to_owned()])) + .edges; + assert_eq!(targets(&g, &edges), vec!["W39", "My weeks"]); +} + +#[wasm_bindgen_test] +fn test_get_filtered_grouped_outgoing_edges_keeps_insertion_order() { + let g = week_graph(); + let grouped = g.get_filtered_grouped_outgoing_edges("W38".to_owned(), None); + let next = grouped.get_edges("next").unwrap(); + assert_eq!(targets(&g, &next), vec!["W39", "My weeks"]); +} + // ---- edge_matches_edge_filter (Rc variant) ---- #[wasm_bindgen_test]