diff --git a/pom.xml b/pom.xml
index 82b239e4..0347ccdb 100644
--- a/pom.xml
+++ b/pom.xml
@@ -38,7 +38,9 @@
             <groupId>org.assertj</groupId>
             <artifactId>assertj-core</artifactId>
             <version>${assertj.version}</version>
+            <scope>test</scope>
         </dependency>
+
         <dependency>
             <groupId>org.junit.jupiter</groupId>
             <artifactId>junit-jupiter-api</artifactId>
diff --git a/src/main/java/com/thealgorithms/datastructures/Node.java b/src/main/java/com/thealgorithms/datastructures/Node.java
new file mode 100644
index 00000000..c8d0e6cb
--- /dev/null
+++ b/src/main/java/com/thealgorithms/datastructures/Node.java
@@ -0,0 +1,32 @@
+package com.thealgorithms.datastructures;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class Node<T> {
+
+    private final T value;
+    private final List<Node<T>> children;
+
+    public Node(final T value) {
+        this.value = value;
+        this.children = new ArrayList<>();
+    }
+
+    public Node(final T value, final List<Node<T>> children) {
+        this.value = value;
+        this.children = children;
+    }
+
+    public T getValue() {
+        return value;
+    }
+
+    public void addChild(Node<T> child) {
+        children.add(child);
+    }
+
+    public List<Node<T>> getChildren() {
+        return children;
+    }
+}
diff --git a/src/main/java/com/thealgorithms/searches/BreadthFirstSearch.java b/src/main/java/com/thealgorithms/searches/BreadthFirstSearch.java
index 99ee6c27..77043a03 100644
--- a/src/main/java/com/thealgorithms/searches/BreadthFirstSearch.java
+++ b/src/main/java/com/thealgorithms/searches/BreadthFirstSearch.java
@@ -1,32 +1,45 @@
 package com.thealgorithms.searches;

-import com.thealgorithms.searches.DepthFirstSearch.Node;
-import java.util.ArrayDeque;
-import java.util.Optional;
-import java.util.Queue;
+import com.thealgorithms.datastructures.Node;
+import java.util.*;

 /**
  * @author: caos321
  * @date: 31 October 2021 (Sunday)
+ * @wiki: https://en.wikipedia.org/wiki/Breadth-first_search
  */
-public class BreadthFirstSearch {
-    public static Optional<Node> search(final Node node, final String name) {
-        if (node.getName().equals(name)) {
+public class BreadthFirstSearch<T> {
+
+    private final List<T> visited = new ArrayList<>();
+
+    public Optional<Node<T>> search(final Node<T> node, final T value) {
+        if (node == null) {
+            return Optional.empty();
+        }
+        if (node.getValue().equals(value)) {
+            // add root node to visited
+            visited.add(value);
             return Optional.of(node);
         }
+        visited.add(node.getValue());

-        Queue<Node> queue = new ArrayDeque<>(node.getSubNodes());
+        Queue<Node<T>> queue = new ArrayDeque<>(node.getChildren());

         while (!queue.isEmpty()) {
-            final Node current = queue.poll();
+            final Node<T> current = queue.poll();
+            visited.add(current.getValue());

-            if (current.getName().equals(name)) {
+            if (current.getValue().equals(value)) {
                 return Optional.of(current);
             }

-            queue.addAll(current.getSubNodes());
+            queue.addAll(current.getChildren());
         }

         return Optional.empty();
     }
+
+    public List<T> getVisited() {
+        return visited;
+    }
 }
diff --git a/src/main/java/com/thealgorithms/searches/DepthFirstSearch.java b/src/main/java/com/thealgorithms/searches/DepthFirstSearch.java
index 3a12537a..781768d8 100644
--- a/src/main/java/com/thealgorithms/searches/DepthFirstSearch.java
+++ b/src/main/java/com/thealgorithms/searches/DepthFirstSearch.java
@@ -1,79 +1,32 @@
 package com.thealgorithms.searches;

+import com.thealgorithms.datastructures.Node;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.Objects;
 import java.util.Optional;

 /**
  * @author: caos321
  * @date: 31 October 2021 (Sunday)
+ * @wiki: https://en.wikipedia.org/wiki/Depth-first_search
  */
-public class DepthFirstSearch {
+public class DepthFirstSearch<T> {

-    static class Node {
+    private final List<T> visited = new ArrayList<>();

-        private final String name;
-        private final List<Node> subNodes;
-
-        public Node(final String name) {
-            this.name = name;
-            this.subNodes = new ArrayList<>();
-        }
-
-        public Node(final String name, final List<Node> subNodes) {
-            this.name = name;
-            this.subNodes = subNodes;
-        }
-
-        public String getName() {
-            return name;
-        }
-
-        public List<Node> getSubNodes() {
-            return subNodes;
+    public Optional<Node<T>> recursiveSearch(final Node<T> node, final Integer value) {
+        if (node == null) {
+            return Optional.empty();
         }
-    }
-
-    public static Optional<Node> search(final Node node, final String name) {
-        if (node.getName().equals(name)) {
+        visited.add(node.getValue());
+        if (node.getValue().equals(value)) {
             return Optional.of(node);
         }

-        return node.getSubNodes().stream().map(value -> search(value, name)).flatMap(Optional::stream).findAny();
-    }
-
-    public static void assertThat(final Object actual, final Object expected) {
-        if (!Objects.equals(actual, expected)) {
-            throw new AssertionError(String.format("expected=%s but was actual=%s", expected, actual));
-        }
+        return node.getChildren().stream().map(v -> recursiveSearch(v, value)).flatMap(Optional::stream).findAny();
     }

-    public static void main(final String[] args) {
-        final Node rootNode = new Node("A", List.of(new Node("B", List.of(new Node("D"), new Node("F", List.of(new Node("H"), new Node("I"))))), new Node("C", List.of(new Node("G"))), new Node("E")));
-
-        {
-            final String expected = "I";
-
-            final Node result = search(rootNode, expected).orElseThrow(() -> new AssertionError("Node not found!"));
-
-            assertThat(result.getName(), expected);
-        }
-
-        {
-            final String expected = "G";
-
-            final Node result = search(rootNode, expected).orElseThrow(() -> new AssertionError("Node not found!"));
-
-            assertThat(result.getName(), expected);
-        }
-
-        {
-            final String expected = "E";
-
-            final Node result = search(rootNode, expected).orElseThrow(() -> new AssertionError("Node not found!"));
-
-            assertThat(result.getName(), expected);
-        }
+    public List<T> getVisited() {
+        return visited;
     }
 }

diff --git a/src/test/java/com/thealgorithms/searches/BreadthFirstSearchTest.java b/src/test/java/com/thealgorithms/searches/BreadthFirstSearchTest.java
index 2a5fe0b9..4c9f16d4 100644
--- a/src/test/java/com/thealgorithms/searches/BreadthFirstSearchTest.java
+++ b/src/test/java/com/thealgorithms/searches/BreadthFirstSearchTest.java
@@ -2,33 +2,101 @@ package com.thealgorithms.searches;

 import static org.junit.jupiter.api.Assertions.*;

+import com.thealgorithms.datastructures.Node;
 import java.util.List;
 import java.util.Optional;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;

-class BreadthFirstSearchTest {
+public class BreadthFirstSearchTest {
+    private Node<String> root;
+    private BreadthFirstSearch<String> bfs;

-    private static final DepthFirstSearch.Node rootNode = new DepthFirstSearch.Node("A",
-        List.of(new DepthFirstSearch.Node("B", List.of(new DepthFirstSearch.Node("D"), new DepthFirstSearch.Node("F", List.of(new DepthFirstSearch.Node("H"), new DepthFirstSearch.Node("I"))))), new DepthFirstSearch.Node("C", List.of(new DepthFirstSearch.Node("G"))), new DepthFirstSearch.Node("E")));
+    // Tree structure:
+    //
+    //        A
+    //      / | \
+    //     B  C  D
+    //    / \
+    //   E   F
+
+    @BeforeEach
+    public void setUp() {
+        // nodes declaration
+        root = new Node<>("A");
+
+        var nodeB = new Node<>("B");
+        var nodeC = new Node<>("C");
+        var nodeD = new Node<>("D");
+
+        var nodeE = new Node<>("E");
+        var nodeF = new Node<>("F");
+
+        // tree initialization
+        root.addChild(nodeB);
+        root.addChild(nodeC);
+        root.addChild(nodeD);
+
+        nodeB.addChild(nodeE);
+        nodeB.addChild(nodeF);
+
+        // create an instance to monitor the visited nodes
+        bfs = new BreadthFirstSearch<>();
+    }

     @Test
-    void searchI() {
-        Optional<DepthFirstSearch.Node> Inode = BreadthFirstSearch.search(rootNode, "I");
-        assertTrue(Inode.isPresent());
-        assertEquals(Inode.get().getName(), "I");
+    public void testSearchRoot() {
+        String expectedValue = "A";
+        List<String> expectedPath = List.of("A");
+
+        // check value
+        Optional<Node<String>> value = bfs.search(root, expectedValue);
+        assertEquals(expectedValue, value.orElse(new Node<>("")).getValue());
+
+        // check path
+        assertArrayEquals(expectedPath.toArray(), bfs.getVisited().toArray());
     }

     @Test
-    void searchG() {
-        Optional<DepthFirstSearch.Node> Gnode = BreadthFirstSearch.search(rootNode, "G");
-        assertTrue(Gnode.isPresent());
-        assertEquals(Gnode.get().getName(), "G");
+    public void testSearchF() {
+        String expectedValue = "F";
+        List<String> expectedPath = List.of("A", "B", "C", "D", "E", "F");
+
+        // check value
+        Optional<Node<String>> value = Optional.of(bfs.search(root, expectedValue).orElse(new Node<>(null)));
+        assertEquals(expectedValue, value.get().getValue());
+
+        // check path
+        assertArrayEquals(expectedPath.toArray(), bfs.getVisited().toArray());
     }

     @Test
-    void searchE() {
-        Optional<DepthFirstSearch.Node> Enode = BreadthFirstSearch.search(rootNode, "E");
-        assertTrue(Enode.isPresent());
-        assertEquals(Enode.get().getName(), "E");
+    void testSearchNull() {
+        List<String> expectedPath = List.of("A", "B", "C", "D", "E", "F");
+        Optional<Node<String>> node = bfs.search(root, null);
+
+        // check value
+        assertTrue(node.isEmpty());
+
+        // check path
+        assertArrayEquals(expectedPath.toArray(), bfs.getVisited().toArray());
+    }
+
+    @Test
+    void testNullRoot() {
+        var value = bfs.search(null, "B");
+        assertTrue(value.isEmpty());
+    }
+
+    @Test
+    void testSearchValueThatNotExists() {
+        List<String> expectedPath = List.of("A", "B", "C", "D", "E", "F");
+        var value = bfs.search(root, "Z");
+
+        // check that the value is empty because it's not exists in the tree
+        assertTrue(value.isEmpty());
+
+        // check path is the whole list
+        assertArrayEquals(expectedPath.toArray(), bfs.getVisited().toArray());
     }
 }
\ No newline at end of file
diff --git a/src/test/java/com/thealgorithms/searches/DepthFirstSearchTest.java b/src/test/java/com/thealgorithms/searches/DepthFirstSearchTest.java
new file mode 100644
index 00000000..f85e9409
--- /dev/null
+++ b/src/test/java/com/thealgorithms/searches/DepthFirstSearchTest.java
@@ -0,0 +1,91 @@
+package com.thealgorithms.searches;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.thealgorithms.datastructures.Node;
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class DepthFirstSearchTest {
+
+    private Node<Integer> root;
+    private DepthFirstSearch<Integer> dfs;
+
+    //
+    // Tree structure:
+    //          1
+    //        / | \
+    //       2  3  4
+    //      / \
+    //     5   6
+
+    @BeforeEach
+    public void setUp() {
+        // nodes declaration
+        root = new Node<>(1);
+
+        var nodeB = new Node<>(2);
+        var nodeC = new Node<>(3);
+        var nodeD = new Node<>(4);
+
+        var nodeE = new Node<>(5);
+        var nodeF = new Node<>(6);
+
+        // tree initialization
+        root.addChild(nodeB);
+        root.addChild(nodeC);
+        root.addChild(nodeD);
+
+        nodeB.addChild(nodeE);
+        nodeB.addChild(nodeF);
+
+        // create an instance to monitor the visited nodes
+        dfs = new DepthFirstSearch<>();
+    }
+
+    @Test
+    public void testSearchRoot() {
+        Integer expectedValue = 1;
+        List<Integer> expectedPath = List.of(1);
+
+        // check value
+        Optional<Node<Integer>> value = dfs.recursiveSearch(root, expectedValue);
+        assertEquals(expectedValue, value.orElse(new Node<>(null)).getValue());
+
+        // check path
+        assertArrayEquals(expectedPath.toArray(), dfs.getVisited().toArray());
+    }
+
+    @Test
+    public void testSearch4() {
+        Integer expectedValue = 4;
+        List<Integer> expectedPath = List.of(1, 2, 5, 6, 3, 4);
+
+        // check value
+        Optional<Node<Integer>> value = dfs.recursiveSearch(root, expectedValue);
+        assertEquals(expectedValue, value.orElse(new Node<>(null)).getValue());
+
+        // check path
+        assertArrayEquals(expectedPath.toArray(), dfs.getVisited().toArray());
+    }
+
+    @Test
+    void testNullRoot() {
+        var value = dfs.recursiveSearch(null, 4);
+        assertTrue(value.isEmpty());
+    }
+
+    @Test
+    void testSearchValueThatNotExists() {
+        List<Integer> expectedPath = List.of(1, 2, 5, 6, 3, 4);
+        var value = dfs.recursiveSearch(root, 10);
+
+        // check that the value is empty because it's not exists in the tree
+        assertTrue(value.isEmpty());
+
+        // check path is the whole list
+        assertArrayEquals(expectedPath.toArray(), dfs.getVisited().toArray());
+    }
+}

<!-- For completed items, change [ ] to [x] -->

- [x] I have read [CONTRIBUTING.md](https://github.com/TheAlgorithms/Java/blob/master/CONTRIBUTING.md).
- [x] This pull request is all my own work -- I have not plagiarized it.
- [x] All filenames are in PascalCase.
- [x] All functions and variable names follow Java naming conventions.
- [x] All new algorithms have a URL in their comments that points to Wikipedia or other similar explanations.

Resolve #4229

DFS and BFS search algorithms  - Resolve #4229


diff --git a/src/main/java/com/thealgorithms/searches/RowColumnWiseSorted2dArrayBinarySearch.java b/src/main/java/com/thealgorithms/searches/RowColumnWiseSorted2dArrayBinarySearch.java
index 3b4b0b08..b40c7554 100644
--- a/src/main/java/com/thealgorithms/searches/RowColumnWiseSorted2dArrayBinarySearch.java
+++ b/src/main/java/com/thealgorithms/searches/RowColumnWiseSorted2dArrayBinarySearch.java
@@ -29,7 +29,7 @@ public class RowColumnWiseSorted2dArrayBinarySearch implements MatrixSearchAlgor

     public static <T extends Comparable<T>> int[] search(T[][] matrix, T target) {
         int rowPointer = 0; // The pointer at 0th row
-        int colPointer = matrix.length - 1; // The pointer at end column
+        int colPointer = matrix[0].length - 1; // The pointer at end column

         while (rowPointer < matrix.length && colPointer >= 0) {
             int comp = target.compareTo(matrix[rowPointer][colPointer]);

diff --git a/src/test/java/com/thealgorithms/searches/RowColumnWiseSorted2dArrayBinarySearchTest.java b/src/test/java/com/thealgorithms/searches/RowColumnWiseSorted2dArrayBinarySearchTest.java
index 39ac5bf0..8d1423cb 100644
--- a/src/test/java/com/thealgorithms/searches/RowColumnWiseSorted2dArrayBinarySearchTest.java
+++ b/src/test/java/com/thealgorithms/searches/RowColumnWiseSorted2dArrayBinarySearchTest.java
@@ -110,4 +110,131 @@ public class RowColumnWiseSorted2dArrayBinarySearchTest {
         assertEquals(expected[0], ans[0]);
         assertEquals(expected[1], ans[1]);
     }
+
+    /**
+     * Tests for a WIDE rectangular matrix (3 rows, 4 columns)
+     */
+    private static final Integer[][] WIDE_RECTANGULAR_MATRIX = {
+        {10, 20, 30, 40},
+        {15, 25, 35, 45},
+        {18, 28, 38, 48},
+    };
+
+    @Test
+    public void rowColumnSorted2dArrayBinarySearchTestWideMatrixMiddle() {
+        Integer target = 25; // A value in the middle
+        int[] ans = RowColumnWiseSorted2dArrayBinarySearch.search(WIDE_RECTANGULAR_MATRIX, target);
+        int[] expected = {1, 1};
+        assertEquals(expected[0], ans[0]);
+        assertEquals(expected[1], ans[1]);
+    }
+
+    @Test
+    public void rowColumnSorted2dArrayBinarySearchTestWideMatrixTopRightCorner() {
+        Integer target = 40; // The top-right corner element
+        int[] ans = RowColumnWiseSorted2dArrayBinarySearch.search(WIDE_RECTANGULAR_MATRIX, target);
+        int[] expected = {0, 3};
+        assertEquals(expected[0], ans[0]);
+        assertEquals(expected[1], ans[1]);
+    }
+
+    @Test
+    public void rowColumnSorted2dArrayBinarySearchTestWideMatrixBottomLeftCorner() {
+        Integer target = 18; // The bottom-left corner element
+        int[] ans = RowColumnWiseSorted2dArrayBinarySearch.search(WIDE_RECTANGULAR_MATRIX, target);
+        int[] expected = {2, 0};
+        assertEquals(expected[0], ans[0]);
+        assertEquals(expected[1], ans[1]);
+    }
+
+    @Test
+    public void rowColumnSorted2dArrayBinarySearchTestWideMatrixTopLeftCorner() {
+        Integer target = 10; // The top-left corner element
+        int[] ans = RowColumnWiseSorted2dArrayBinarySearch.search(WIDE_RECTANGULAR_MATRIX, target);
+        int[] expected = {0, 0};
+        assertEquals(expected[0], ans[0]);
+        assertEquals(expected[1], ans[1]);
+    }
+
+    @Test
+    public void rowColumnSorted2dArrayBinarySearchTestWideMatrixBottomRightCorner() {
+        Integer target = 48; // The bottom-right corner element
+        int[] ans = RowColumnWiseSorted2dArrayBinarySearch.search(WIDE_RECTANGULAR_MATRIX, target);
+        int[] expected = {2, 3};
+        assertEquals(expected[0], ans[0]);
+        assertEquals(expected[1], ans[1]);
+    }
+
+    @Test
+    public void rowColumnSorted2dArrayBinarySearchTestWideMatrixNotFound() {
+        Integer target = 99; // A value that does not exist
+        int[] ans = RowColumnWiseSorted2dArrayBinarySearch.search(WIDE_RECTANGULAR_MATRIX, target);
+        int[] expected = {-1, -1};
+        assertEquals(expected[0], ans[0]);
+        assertEquals(expected[1], ans[1]);
+    }
+
+    /**
+     * Tests for a TALL rectangular matrix (4 rows, 3 columns)
+     */
+    private static final Integer[][] TALL_RECTANGULAR_MATRIX = {
+        {10, 20, 30},
+        {15, 25, 35},
+        {18, 28, 38},
+        {21, 31, 41},
+    };
+
+    @Test
+    public void rowColumnSorted2dArrayBinarySearchTestTallMatrixMiddle() {
+        Integer target = 28; // A value in the middle
+        int[] ans = RowColumnWiseSorted2dArrayBinarySearch.search(TALL_RECTANGULAR_MATRIX, target);
+        int[] expected = {2, 1};
+        assertEquals(expected[0], ans[0]);
+        assertEquals(expected[1], ans[1]);
+    }
+
+    @Test
+    public void rowColumnSorted2dArrayBinarySearchTestTallMatrixTopRightCorner() {
+        Integer target = 30; // The top-right corner element
+        int[] ans = RowColumnWiseSorted2dArrayBinarySearch.search(TALL_RECTANGULAR_MATRIX, target);
+        int[] expected = {0, 2};
+        assertEquals(expected[0], ans[0]);
+        assertEquals(expected[1], ans[1]);
+    }
+
+    @Test
+    public void rowColumnSorted2dArrayBinarySearchTestTallMatrixBottomLeftCorner() {
+        Integer target = 21; // The bottom-left corner element
+        int[] ans = RowColumnWiseSorted2dArrayBinarySearch.search(TALL_RECTANGULAR_MATRIX, target);
+        int[] expected = {3, 0};
+        assertEquals(expected[0], ans[0]);
+        assertEquals(expected[1], ans[1]);
+    }
+
+    @Test
+    public void rowColumnSorted2dArrayBinarySearchTestTallMatrixTopLeftCorner() {
+        Integer target = 10; // The top-left corner element
+        int[] ans = RowColumnWiseSorted2dArrayBinarySearch.search(TALL_RECTANGULAR_MATRIX, target);
+        int[] expected = {0, 0};
+        assertEquals(expected[0], ans[0]);
+        assertEquals(expected[1], ans[1]);
+    }
+
+    @Test
+    public void rowColumnSorted2dArrayBinarySearchTestTallMatrixBottomRightCorner() {
+        Integer target = 41; // The bottom-right corner element
+        int[] ans = RowColumnWiseSorted2dArrayBinarySearch.search(TALL_RECTANGULAR_MATRIX, target);
+        int[] expected = {3, 2};
+        assertEquals(expected[0], ans[0]);
+        assertEquals(expected[1], ans[1]);
+    }
+
+    @Test
+    public void rowColumnSorted2dArrayBinarySearchTestTallMatrixNotFound() {
+        Integer target = 5; // A value that does not exist
+        int[] ans = RowColumnWiseSorted2dArrayBinarySearch.search(TALL_RECTANGULAR_MATRIX, target);
+        int[] expected = {-1, -1};
+        assertEquals(expected[0], ans[0]);
+        assertEquals(expected[1], ans[1]);
+    }
 }

<!--
Thank you for your contribution!
In order to reduce the number of notifications sent to the maintainers, please:
- create your PR as draft, cf. https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests#draft-pull-requests,
- make sure that all of the CI checks pass,
- mark your PR as ready for review, cf. https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/changing-the-stage-of-a-pull-request#marking-a-pull-request-as-ready-for-review
-->

<!-- For completed items, change [ ] to [x] -->

- [x] I have read [CONTRIBUTING.md](https://github.com/TheAlgorithms/Java/blob/master/CONTRIBUTING.md).
- [x] This pull request is all my own work -- I have not plagiarized it.
- [x] All filenames are in PascalCase.
- [x] All functions and variable names follow Java naming conventions.
- [x] All new algorithms have a URL in their comments that points to Wikipedia or other similar explanations.
- [x] All new code is formatted with `clang-format -i --style=file path/to/your/file.java`

Closes #6332

This pull request fixes a bug in the `RowColumnWiseSorted2dArrayBinarySearch.java` file where the column pointer was incorrectly initialized.

**The Problem:**
The column pointer (`colPointer`) was being initialized with `matrix.length - 1`. However, `matrix.length` returns the number of rows, not columns. This causes the algorithm to start at an incorrect position and fail for any matrix that is not square.

**The Solution:**
I have corrected the initialization to `int colPointer = matrix[0].length - 1;`. This correctly uses the length of the first row to determine the number of columns, ensuring the pointer is set to the last column's index as intended by the algorithm's logic.

Fix(searches): Correct column pointer initialization in RowColumnWiseSorted2dArrayBinarySearch

def parse_java_mvn(log: str) -> dict[str, str]:
    """Parse Maven logs into {identifier: status}."""
    results = {}

    failure_re = re.compile(
        r"^\[ERROR\]\s+(?P<class>[A-Za-z0-9_$.]+)\.(?P<method>[A-Za-z0-9_$.]+?)(?::(?P<line>\d+))?\b"
    )
    running_re = re.compile(r"^(?:\[INFO\]\s+)?Running\s+([A-Za-z0-9_$.]+)$")
    summary_re = re.compile(
        r"Tests run:\s*(\d+),\s*Failures:\s*(\d+),\s*Errors:\s*(\d+),\s*Skipped:\s*(\d+)"
    )

    seen_running = set()
    current_running = None

    for raw in log.splitlines():
        line = raw.strip()
        if not line:
            continue
        rm = running_re.match(line)
        if rm:
            running_name = rm.group(1)
            current_running = running_name
            seen_running.add(running_name)
            continue
        fm = failure_re.match(line)
        if fm:
            clazz = fm.group("class")
            method = fm.group("method")
            test_id = f"{clazz}.{method}"
            results[test_id] = "FAILED"
            results.setdefault(clazz, "FAILED")
            continue

        sm = summary_re.search(line)
        if sm:
            _tests_run, failures, errors, skipped = map(int, sm.groups())
            if failures > 0 or errors > 0:
                status = "FAILED"
            elif skipped > 0:
                status = "SKIPPED"
            else:
                status = "PASSED"
            if current_running:
                results.setdefault(current_running, status)
            current_running = None

    if not results and seen_running:
        for clazz in seen_running:
            results.setdefault(clazz, "PASSED")
    return results

bash -c '{ mvn --batch-mode test ; } > /tmp/test.log 2>&1'


diff --git a/README.md b/README.md
index 570405b06..a31129741 100644
--- a/README.md
+++ b/README.md
@@ -20,11 +20,8 @@ Community forum: **https://forum.openrefine.org**

 ## Snapshot releases

-Latest development version, packaged for:
-* [Linux](https://oss.sonatype.org/service/local/artifact/maven/content?r=snapshots&g=org.openrefine&a=openrefine&v=3.8-SNAPSHOT&c=linux&p=tar.gz)
-* [MacOS](https://oss.sonatype.org/service/local/artifact/maven/content?r=snapshots&g=org.openrefine&a=openrefine&v=3.8-SNAPSHOT&c=mac&p=dmg)
-* [Windows without embedded JRE](https://oss.sonatype.org/service/local/artifact/maven/content?r=snapshots&g=org.openrefine&a=openrefine&v=3.8-SNAPSHOT&c=win&p=zip)
-* [Windows with embedded JRE](https://oss.sonatype.org/service/local/artifact/maven/content?r=snapshots&g=org.openrefine&a=openrefine&v=3.8-SNAPSHOT&c=win-with-java&p=zip)
+You can download snapshots of the development version of OpenRefine.
+To do so, you need to be logged in to GitHub. Then, click on the first item with a green tick / check mark on [this page](https://github.com/OpenRefine/OpenRefine/actions/workflows/snapshot_release.yml) and scroll down to the Artifacts section to find the version that matches your operating system.

 ## Run from source

diff --git a/main/src/com/google/refine/ProjectMetadata.java b/main/src/com/google/refine/ProjectMetadata.java
index 50cef88de..b90e18228 100644
--- a/main/src/com/google/refine/ProjectMetadata.java
+++ b/main/src/com/google/refine/ProjectMetadata.java
@@ -49,6 +49,8 @@ import org.slf4j.LoggerFactory;
 import com.fasterxml.jackson.annotation.JsonIgnore;
 import com.fasterxml.jackson.annotation.JsonInclude;
 import com.fasterxml.jackson.annotation.JsonInclude.Include;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.json.JsonReadFeature;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import com.fasterxml.jackson.annotation.JsonView;
 import com.fasterxml.jackson.databind.node.ArrayNode;
@@ -374,9 +376,17 @@ public class ProjectMetadata {
             Field metaField = metaClass.getDeclaredField("_" + metaName);
             if (metaName.equals("tags")) {
                 metaField.set(this, valueString.split(","));
+            } else if (metaName.equals("customMetadata")) {
+                ParsingUtilities.mapper.enable(JsonReadFeature.ALLOW_BACKSLASH_ESCAPING_ANY_CHARACTER.mappedFeature());
+                Map<String, Object> map = ParsingUtilities.mapper.readValue(valueString, HashMap.class);
+                metaField.set(this, map);
             } else {
                 metaField.set(this, valueString);
             }
+        } catch (JsonProcessingException e) {
+            String errorMessage = "Error reading JSON: " + e.getOriginalMessage();
+            logger.error(errorMessage);
+            throw new RuntimeException(errorMessage);
         } catch (NoSuchFieldException e) {
             updateUserMetadata(metaName, valueString);
         } catch (SecurityException | IllegalArgumentException | IllegalAccessException e) {
diff --git a/packaging/pom.xml b/packaging/pom.xml
index 99dfd5890..a07206e70 100644
--- a/packaging/pom.xml
+++ b/packaging/pom.xml
@@ -513,88 +513,6 @@
           <skip>true</skip>
         </configuration>
       </plugin>
-
-      <plugin>
-        <groupId>org.codehaus.mojo</groupId>
-        <artifactId>build-helper-maven-plugin</artifactId>
-        <version>${build-helper-maven-plugin.version}</version>
-        <inherited>false</inherited>
-        <executions>
-          <!-- Attach tar.gz artifact for linux -->
-          <execution>
-              <id>attach-linux</id>
-              <phase>deploy</phase>
-              <goals>
-                  <goal>attach-artifact</goal>
-              </goals>
-              <configuration>
-                  <artifacts>
-                      <artifact>
-                          <file>${project.build.directory}/${project.build.finalName}-linux-${project.version}.tar.gz</file>
-                          <type>tar.gz</type>
-                          <classifier>linux</classifier>
-                      </artifact>
-                  </artifacts>
-              </configuration>
-          </execution>
-
-          <!-- Attach the zip file for windows, without embedded JRE -->
-          <execution>
-              <id>attach-win-without-jre</id>
-              <phase>deploy</phase>
-              <goals>
-                  <goal>attach-artifact</goal>
-              </goals>
-              <configuration>
-                  <skipAttach>${skipCreateExe}</skipAttach>
-                  <artifacts>
-                      <artifact>
-                          <file>${project.build.directory}/${project.build.finalName}-win-${project.version}.zip</file>
-                          <type>zip</type>
-                          <classifier>win</classifier>
-                      </artifact>
-                  </artifacts>
-              </configuration>
-          </execution>
-
-          <!-- Attach the zip file for windows, with embedded JRE -->
-          <execution>
-              <id>attach-win-with-jre</id>
-              <phase>deploy</phase>
-              <goals>
-                  <goal>attach-artifact</goal>
-              </goals>
-              <configuration>
-                  <skipAttach>${skipCreateExe}</skipAttach>
-                  <artifacts>
-                      <artifact>
-                          <file>${project.build.directory}/${project.build.finalName}-win-with-java-${project.version}.zip</file>
-                          <type>zip</type>
-                          <classifier>win-with-java</classifier>
-                      </artifact>
-                  </artifacts>
-              </configuration>
-          </execution>
-
-          <!-- Attach the final DMG for MacOS -->
-          <execution>
-              <id>attach-dmg</id>
-              <phase>deploy</phase>
-              <goals>
-                  <goal>attach-artifact</goal>
-              </goals>
-              <configuration>
-                  <artifacts>
-                      <artifact>
-                          <file>${project.build.directory}/${project.build.finalName}-mac-${project.version}.dmg</file>
-                          <type>dmg</type>
-                          <classifier>mac</classifier>
-                      </artifact>
-                  </artifacts>
-              </configuration>
-          </execution>
-        </executions>
-      </plugin>
     </plugins>
     <pluginManagement>
     	<plugins>

diff --git a/main/tests/cypress/package.json b/main/tests/cypress/package.json
index 739d96726..c9b25e828 100644
--- a/main/tests/cypress/package.json
+++ b/main/tests/cypress/package.json
@@ -20,7 +20,7 @@
         "cypress-file-upload": "^5.0.8"
     },
     "devDependencies": {
-        "eslint": "^8.44.0",
+        "eslint": "^8.45.0",
         "eslint-config-google": "^0.14.0",
         "eslint-config-prettier": "^8.8.0",
         "eslint-plugin-cypress": "^2.13.3",
diff --git a/main/tests/cypress/yarn.lock b/main/tests/cypress/yarn.lock
index 8bf8be48c..066e8d958 100644
--- a/main/tests/cypress/yarn.lock
+++ b/main/tests/cypress/yarn.lock
@@ -594,10 +594,10 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1:
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz#c22c48f48942d08ca824cc526211ae400478a994"
   integrity sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==

-eslint@^8.44.0:
-  version "8.44.0"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.44.0.tgz#51246e3889b259bbcd1d7d736a0c10add4f0e500"
-  integrity sha512-0wpHoUbDUHgNCyvFB5aXLiQVfK9B0at6gUvzy83k4kAsQ/u769TQDX6iKC+aO4upIHO9WSaA3QoXYQDHbNwf1A==
+eslint@^8.45.0:
+  version "8.45.0"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.45.0.tgz#bab660f90d18e1364352c0a6b7c6db8edb458b78"
+  integrity sha512-pd8KSxiQpdYRfYa9Wufvdoct3ZPQQuVuU5O6scNgMuOMYuxvH0IGaYK0wUFjo4UYYQQCUndlXiMbnxopwvvTiw==
   dependencies:
     "@eslint-community/eslint-utils" "^4.2.0"
     "@eslint-community/regexpp" "^4.4.0"
@@ -624,7 +624,6 @@ eslint@^8.44.0:
     globals "^13.19.0"
     graphemer "^1.4.0"
     ignore "^5.2.0"
-    import-fresh "^3.0.0"
     imurmurhash "^0.1.4"
     is-glob "^4.0.0"
     is-path-inside "^3.0.3"
@@ -636,7 +635,6 @@ eslint@^8.44.0:
     natural-compare "^1.4.0"
     optionator "^0.9.3"
     strip-ansi "^6.0.1"
-    strip-json-comments "^3.1.0"
     text-table "^0.2.0"

 espree@^9.6.0:
@@ -948,7 +946,7 @@ ignore@^5.2.0:
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
   integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==

-import-fresh@^3.0.0, import-fresh@^3.2.1:
+import-fresh@^3.2.1:
   version "3.3.0"
   resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz"
   integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==
@@ -1511,7 +1509,7 @@ strip-final-newline@^2.0.0:
   resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz"
   integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==

-strip-json-comments@^3.1.0, strip-json-comments@^3.1.1:
+strip-json-comments@^3.1.1:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz"
   integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
diff --git a/main/tests/server/src/com/google/refine/commands/project/SetProjectMetadataCommandTests.java b/main/tests/server/src/com/google/refine/commands/project/SetProjectMetadataCommandTests.java
index d80f3b8fb..bbee28a84 100644
--- a/main/tests/server/src/com/google/refine/commands/project/SetProjectMetadataCommandTests.java
+++ b/main/tests/server/src/com/google/refine/commands/project/SetProjectMetadataCommandTests.java
@@ -41,6 +41,8 @@ import static org.mockito.Mockito.when;

 import java.io.IOException;
 import java.io.PrintWriter;
+import java.util.List;
+import java.util.Map;

 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServletRequest;
@@ -211,4 +213,42 @@ public class SetProjectMetadataCommandTests extends RefineTest {
         verify(request, times(2)).getParameter("project");
         verify(projMan, times(1)).getProject(PROJECT_ID_LONG);
     }
+
+    @Test
+    public void setCustomMetadataTest() {
+        String customMetadata = this.getValidCustomMetadata();
+        ProjectMetadata meta = proj.getMetadata();
+        meta.setAnyField("customMetadata", customMetadata);
+
+        Assert.assertEquals(meta.getCustomMetadata("name"), "test");
+        Assert.assertEquals(meta.getCustomMetadata("isValid"), true);
+        Assert.assertEquals(meta.getCustomMetadata("id"), 1);
+        Assert.assertTrue(meta.getCustomMetadata("companies") instanceof List);
+        Assert.assertTrue(meta.getCustomMetadata("information") instanceof Map);
+    }
+
+    @Test
+    public void setInvalidCustomMetadataTest() {
+        String customMetadata = this.getInvalidCustomMetadata();
+        ProjectMetadata meta = proj.getMetadata();
+
+        Assert.assertThrows(() -> meta.setAnyField("customMetadata", customMetadata));
+    }
+
+    private String getValidCustomMetadata() {
+        return "{\n" + //
+                "  \"name\":\"test\",\n" + //
+                "  \"companies\":[\"Google\", \"IBM\", \"meta\"],\n" + //
+                "  \"isValid\": true,\n" + //
+                "  \"information\": {\n" + //
+                "    \"text-field\":\"value\",\n" + //
+                "    \"numeric-field\": 1\n" + //
+                "  },\n" + //
+                "  \"id\":1\n" + //
+                "}";
+    }
+
+    private String getInvalidCustomMetadata() {
+        return "{\n  \"name\":test\n}";
+    }
 }

Fixes #5772

Changes proposed in this pull request:
- edit setAnyField() method in ProjectMetadata class
- add case to edit customMetadata field
- parse given String to JSON and JSON to HashMap

def parser(log: str) -> dict[str, str]:
    import re
    import xml.etree.ElementTree as ET

    results: dict[str, str] = {}

    # Helper: merge statuses with precedence fail > skip > pass
    def merge_status(name: str, new_status: str):
        if new_status not in ("pass", "fail", "skip"):
            return
        old_status = results.get(name)
        if old_status is None:
            results[name] = new_status
            return
        if old_status == new_status:
            return
        # Precedence: fail > skip > pass
        priority = {"fail": 3, "skip": 2, "pass": 1}
        if priority[new_status] > priority[old_status]:
            results[name] = new_status

    # -----------------------
    # XML-based parsing pass
    # -----------------------

    # Non-greedy patterns for JUnit/TestNG XML blocks
    xml_pattern = re.compile(
        r"(<testng-results[\s\S]*?</testng-results>|<testsuite[\s\S]*?</testsuite>)",
        re.IGNORECASE,
    )

    for match in xml_pattern.finditer(log):
        xml_block = match.group(1)
        try:
            root = ET.fromstring(xml_block)
        except ET.ParseError:
            # Skip this block but continue with others
            continue

        tag_lower = root.tag.lower()

        # Handle TestNG summary reports (<testng-results>)
        if tag_lower.endswith("testng-results"):
            # Look for all test-method elements anywhere
            for tm in root.findall(".//test-method"):
                # Skip configuration methods
                if tm.attrib.get("is-config", "false").lower() == "true":
                    continue
                name = tm.attrib.get("name", "")
                if not name:
                    continue
                # Try to get class name from surrounding <class> element
                class_elem = tm.find("..")
                class_name = ""
                # xml.etree doesn't support parent lookup directly, so search ancestors:
                # We instead search for the nearest <class> ancestor manually:
            # Since parent lookup is tricky, fall back to the attribute 'class' on <test-method> if present
            # or to a surrounding <class> element found via xpath with name match, which is usually enough.
            # Re-parse with a simpler loop using suites/tests/classes for better class name resolution.
            for suite in root.findall(".//suite"):
                for test in suite.findall(".//test"):
                    for clazz in test.findall(".//class"):
                        class_name = clazz.attrib.get("name", "")
                        for tm in clazz.findall("test-method"):
                            if tm.attrib.get("is-config", "false").lower() == "true":
                                continue
                            m_name = tm.attrib.get("name", "")
                            if not m_name:
                                continue
                            fq_name = f"{class_name}.{m_name}" if class_name else m_name
                            status_attr = tm.attrib.get("status", "").upper()
                            # Map TestNG status to our statuses
                            if status_attr == "PASS":
                                status = "pass"
                            elif status_attr in ("FAIL", "FAILURE"):
                                status = "fail"
                            elif status_attr == "SKIP":
                                status = "skip"
                            else:
                                # Fallback: if there is an exception element, mark as fail; else pass
                                if tm.find(".//exception") is not None:
                                    status = "fail"
                                else:
                                    status = "pass"
                            merge_status(fq_name, status)
            continue

        # Handle JUnit-style <testsuite> reports
        if tag_lower.endswith("testsuite"):
            for tc in root.findall(".//testcase"):
                class_name = tc.attrib.get("classname", "")
                name = tc.attrib.get("name", "")
                if not name:
                    continue
                fq_name = f"{class_name}.{name}" if class_name else name

                # Determine status via children
                status = "pass"
                if tc.find("skipped") is not None:
                    status = "skip"
                elif tc.find("failure") is not None or tc.find("error") is not None:
                    status = "fail"

                merge_status(fq_name, status)

    # ---------------------------
    # Text-based parsing fallback
    # ---------------------------

    # Compile regexes for common frameworks

    # pytest: path::test_name STATUS
    pytest_re = re.compile(
        r"^(?P<name>\S+::\S+)\s+"
        r"(?P<status>PASSED|FAILED|SKIPPED|ERROR|XFAILED|XPASSED)\b"
    )

    # unittest / nose: ClassName.test_method ... ok/FAIL/ERROR/skipped
    unittest_re = re.compile(
        r"^(?P<class>[A-Za-z_]\w*)\.(?P<method>[A-Za-z_]\w*)\s+\.\.\.\s+"
        r"(?P<status>ok|FAIL|ERROR|skipped)\b"
    )

    # go test: --- STATUS: TestName
    gotest_re = re.compile(
        r"^---\s+(?P<status>PASS|FAIL|SKIP):\s+(?P<name>\S+)"
    )

    # Jest-like symbol lines: ✓/✕/○ test name
    jest_symbol_re = re.compile(r"^\s*([✓✕○])\s+(?P<name>.+)")

    # Map various raw statuses to our canonical ones
    def map_status(raw: str) -> str | None:
        r = raw.upper()
        if r in ("PASSED", "OK", "XPASSED", "PASS"):
            return "pass"
        if r in ("FAILED", "FAIL", "ERROR", "XFAILED"):
            return "fail"
        if r in ("SKIPPED", "SKIP", "SKIPPED (EXPECTED FAILURE)"):
            return "skip"
        return None

    for line in log.splitlines():
        line = line.rstrip("\n")

        # pytest
        m = pytest_re.match(line)
        if m:
            name = m.group("name")
            status = map_status(m.group("status"))
            if status:
                merge_status(name, status)
            continue

        # unittest
        m = unittest_re.match(line)
        if m:
            name = f"{m.group('class')}.{m.group('method')}"
            status = map_status(m.group("status"))
            if status:
                merge_status(name, status)
            continue

        # go test
        m = gotest_re.match(line)
        if m:
            name = m.group("name")
            status = map_status(m.group("status"))
            if status:
                merge_status(name, status)
            continue

        # Jest symbols
        m = jest_symbol_re.match(line)
        if m:
            symbol = m.group(1)
            name = m.group("name").strip()
            if symbol == "✓":
                status = "pass"
            elif symbol == "✕":
                status = "fail"
            elif symbol == "○":
                status = "skip"
            else:
                status = None
            if status:
                merge_status(name, status)
            continue

    return results

export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq nodejs npm
./refine build

find . -maxdepth 6 -path '*surefire-reports/*.xml' -print0 | xargs -0 -I {} sh -c 'echo "===== {} ====="; cat {}'


diff --git a/main/resources/com/google/refine/grel/ControlEvalError.properties b/main/resources/com/google/refine/grel/ControlEvalError.properties
index 9cb0e415e..d2a138e61 100644
--- a/main/resources/com/google/refine/grel/ControlEvalError.properties
+++ b/main/resources/com/google/refine/grel/ControlEvalError.properties
@@ -1,5 +1,5 @@
 filter=First argument is not an array
-foreach_index=First argument to forEach is not an array
+foreach_index=First argument to forEachIndex is not an array or JSON object
 foreach=First argument to forEach is not an array or JSON object
 for_range=First, second, and third arguments of forRange must all be numbers

@@ -12,3 +12,4 @@ expects_five_args={0} expects five arguments
 expects_second_arg_var_name={0} expects second argument to be a variable name
 expects_second_arg_index_var_name={0} expects second argument to be the index's variable name
 expects_third_arg_element_var_name={0} expects second argument to be the element's variable name
+expects_second_third_args_different={0} expects the second and third arguments to be different variable names
diff --git a/main/src/com/google/refine/grel/ast/VariableExpr.java b/main/src/com/google/refine/grel/ast/VariableExpr.java
index 4ed7a3370..3188a5dc2 100644
--- a/main/src/com/google/refine/grel/ast/VariableExpr.java
+++ b/main/src/com/google/refine/grel/ast/VariableExpr.java
@@ -45,7 +45,11 @@ public class VariableExpr implements Evaluable {
     final protected String _name;

     public VariableExpr(String name) {
-        _name = name;
+        if (name != null || !name.isEmpty()) {
+            _name = name;
+        } else {
+            throw new IllegalArgumentException("Illegal variable name ");
+        }
     }

     @Override
@@ -61,4 +65,14 @@ public class VariableExpr implements Evaluable {
     public String getName() {
         return _name;
     }
+
+    @Override
+    public boolean equals(Object other) {
+        return other instanceof VariableExpr && getName().equals(((VariableExpr) other).getName());
+    }
+
+    @Override
+    public int hashCode() {
+        return _name.hashCode();
+    }
 }
diff --git a/main/src/com/google/refine/grel/controls/ForEachIndex.java b/main/src/com/google/refine/grel/controls/ForEachIndex.java
index 53cf9ca18..367781026 100644
--- a/main/src/com/google/refine/grel/controls/ForEachIndex.java
+++ b/main/src/com/google/refine/grel/controls/ForEachIndex.java
@@ -34,10 +34,16 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 package com.google.refine.grel.controls;

 import java.util.ArrayList;
+import java.util.Iterator;
 import java.util.List;
+import java.util.Map;
 import java.util.Properties;

+import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import com.google.refine.expr.EvalError;
 import com.google.refine.expr.Evaluable;
 import com.google.refine.expr.ExpressionUtils;
 import com.google.refine.expr.util.JsonValueConverter;
@@ -59,6 +65,8 @@ public class ForEachIndex implements Control {
         } else if (!(args[2] instanceof VariableExpr)) {
             // variable name";
             return ControlEvalError.expects_third_arg_element_var_name(ControlFunctionRegistry.getControlName(this));
+        } else if (args[1].equals(args[2])) {
+            return ControlEvalError.expects_second_third_args_different(ControlFunctionRegistry.getControlName(this));
         }
         return null;
     }
@@ -68,8 +76,8 @@ public class ForEachIndex implements Control {
         Object o = args[0].evaluate(bindings);
         if (ExpressionUtils.isError(o)) {
             return o;
-        } else if (!ExpressionUtils.isArrayOrCollection(o) && !(o instanceof ArrayNode)) {
-            return ControlEvalError.foreach_index();
+        } else if (!ExpressionUtils.isArrayOrCollection(o) && !(o instanceof ArrayNode) && !(o instanceof ObjectNode)) {
+            return new EvalError(ControlEvalError.foreach_index());
         }

         String indexName = ((VariableExpr) args[1]).getName();
@@ -108,6 +116,21 @@ public class ForEachIndex implements Control {

                     Object r = args[3].evaluate(bindings);

+                    results.add(r);
+                }
+            } else if (o instanceof ObjectNode) {
+                ObjectNode obj = (ObjectNode) o;
+                results = new ArrayList<>(obj.size());
+                for (Iterator<Map.Entry<String, JsonNode>> it = obj.fields(); it.hasNext();) {
+                    Map.Entry<String, JsonNode> entry = it.next();
+                    if (entry != null) {
+                        bindings.put(indexName, entry.getKey());
+                        bindings.put(elementName, entry.getValue());
+                    } else {
+                        bindings.remove(indexName);
+                        bindings.remove(elementName);
+                    }
+                    Object r = args[3].evaluate(bindings);
                     results.add(r);
                 }
             } else {

diff --git a/main/tests/server/src/com/google/refine/grel/controls/ForEachIndexTests.java b/main/tests/server/src/com/google/refine/grel/controls/ForEachIndexTests.java
index d2d353cbf..6f25f3ee9 100644
--- a/main/tests/server/src/com/google/refine/grel/controls/ForEachIndexTests.java
+++ b/main/tests/server/src/com/google/refine/grel/controls/ForEachIndexTests.java
@@ -1,5 +1,5 @@
 /*******************************************************************************
- * Copyright (C) 2018, OpenRefine contributors
+ * Copyright (C) 2018, 2023, OpenRefine contributors
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -27,15 +27,90 @@

 package com.google.refine.grel.controls;

+import static org.testng.Assert.fail;
+
+import java.util.Properties;
+
 import org.testng.annotations.Test;

+import com.google.refine.RefineTest;
+import com.google.refine.expr.MetaParser;
+import com.google.refine.expr.ParsingException;
 import com.google.refine.util.TestUtils;

-public class ForEachIndexTests {
+public class ForEachIndexTests extends RefineTest {

     @Test
     public void serializeForEachIndex() {
         String json = "{\"description\":\"Evaluates expression a to an array. Then for each array element, binds its index to variable i and its value to variable name v, evaluates expression e, and pushes the result onto the result array.\",\"params\":\"expression a, variable i, variable v, expression e\",\"returns\":\"array\"}";
         TestUtils.isSerializedTo(new ForEachIndex(), json);
     }
+
+    private void assertParseException(String expression) {
+        assertParseException(expression, null);
+    }
+
+    private void assertParseException(String expression, String message) {
+        try {
+            MetaParser.parse("grel:" + expression);
+            if (message == null) {
+                fail("Expression didn't return error : " + expression);
+            } else {
+                fail(message);
+            }
+        } catch (ParsingException e) {
+            // success
+        }
+    }
+
+    @Test
+    public void testInvalidParams() throws ParsingException {
+        bindings = new Properties();
+        bindings.put("v", "");
+        assertParseException("forEachIndex('test', v, v)");
+        assertParseException("forEachIndex([], 1, 1, 1)", "Didn't throw a ParsingException for wrong argument type");
+        assertParseException("forEachIndex([], v, v, v)", "Didn't throw ParsingException for duplicate variables");
+        assertParseException("forEachIndex([], v, v)", "Didn't throw a ParsingException for 3 arguments");
+        assertParseException("forEachIndex([], v)", "Didn't throw a ParsingException for 2 arguments");
+        assertParseException("forEachIndex([])", "Didn't throw a ParsingException for 1 argument");
+    }
+
+    @Test
+    public void testForEachJsonObject() throws ParsingException {
+        String json = "{"
+                + "\"2511\": {\"parent_area\": null, \"generation_high\": 40, \"all_names\": {}, \"id\": 2511, \"codes\": {\"ons\": \"00AB\", \"gss\": \"E09000002\", \"local-authority-eng\": \"BDG\", \"local-authority-canonical\": \"BDG\", \"unit_id\": \"10949\"}, \"name\": \"Barking and Dagenham Borough Council\", \"country\": \"E\", \"type_name\": \"London borough\", \"generation_low\": 1, \"country_name\": \"England\", \"type\": \"LBO\"},"
+                + "\"2247\": {\"parent_area\": null, \"generation_high\": 40, \"all_names\": {}, \"id\": 2247, \"codes\": {\"unit_id\": \"41441\"}, \"name\": \"Greater London Authority\", \"country\": \"E\", \"type_name\": \"Greater London Authority\", \"generation_low\": 1, \"country_name\": \"England\", \"type\": \"GLA\"},"
+                + "\"8706\": {\"parent_area\": 2511, \"generation_high\": 40, \"all_names\": {}, \"id\": 8706, \"codes\": {\"ons\": \"00ABGH\", \"gss\": \"E05000036\", \"unit_id\": \"10936\"}, \"name\": \"Mayesbrook\", \"country\": \"E\", \"type_name\": \"London borough ward\", \"generation_low\": 1, \"country_name\": \"England\", \"type\": \"LBW\"}"
+                + "}";
+
+        String[] test = { "forEachIndex('" + json + "'.parseJson(), k, v, v.id).sort().join(',')", "2247,2511,8706" };
+        bindings = new Properties();
+        bindings.put("k", "");
+        bindings.put("v", "");
+        parseEval(bindings, test);
+
+        String[] test2 = { "forEachIndex('" + json + "'.parseJson(), k, v, k).sort().join(',')", "2247,2511,8706" };
+        parseEval(bindings, test2);
+        String[] test3 = { "forEachIndex('" + json + "'.parseJson(), k, v, k.toNumber()==v.id).join(',')", "true,true,true" };
+        parseEval(bindings, test3);
+    }
+
+    @Test
+    public void testForEachIndexArray() throws ParsingException {
+        bindings = new Properties();
+        bindings.put("k", "");
+        bindings.put("v", "");
+        parseEval(bindings, new String[] { "forEachIndex([5,4,3,2.0], k, v, v*2).join(',')", "10,8,6,4.0" });
+        parseEval(bindings, new String[] { "forEachIndex([5,4,3,2.0], k, v, k).join(',')", "0,1,2,3" });
+    }
+
+    @Test
+    public void testForEachIndexJsonArray() throws ParsingException {
+        bindings = new Properties();
+        bindings.put("k", "");
+        bindings.put("v", "");
+        parseEval(bindings, new String[] { "forEachIndex('[3,2,1.0]'.parseJson(), k, v, v*2).join(',')", "6,4,2.0" });
+        parseEval(bindings, new String[] { "forEachIndex('[3,2,1.0]'.parseJson(), k, v, k).join(',')", "0,1,2" });
+    }
+
 }

Fixes #3147

I'm on the fence as to whether this should be a separate control (name TBD), as I originally spec'd, or if rolling the functionality into the existing `forEachIndex` is close enough semantically given that the array index and the object key are both performing essentially the same function. This pull request adopts the latter approach. Doing it the other way would basically involve cloning the code, giving it a new function name, and making the error messages more specific.

Changes proposed in this pull request:
- add tests for `forEachIndex`
- enhance error checking to reject using the same variable name for both index and value
- add support for JSON objects withe same structure, but the object key instead of the array index returned in the index/key variable

Add support for JSON Objects to forEachIndex() control


diff --git a/.github/workflows/snapshot_release.yml b/.github/workflows/snapshot_release.yml
index 634d7e676..4cd3ce040 100644
--- a/.github/workflows/snapshot_release.yml
+++ b/.github/workflows/snapshot_release.yml
@@ -350,7 +350,7 @@ jobs:

     - name: Download build artifact for Windows Installer (with Java)
       id: download-build-artifact-win-with-java
-      uses: actions/download-artifact@v3
+      uses: actions/download-artifact@v4
       with:
         name: installertemp-win-with-java-${{ needs.mac_test_and_deploy.outputs.version_number_string }}
         path: ./packaging/target/openrefine-win-with-java-${{ needs.mac_test_and_deploy.outputs.version_number_string }}
diff --git a/extensions/database/pom.xml b/extensions/database/pom.xml
index 0a294e654..02c073747 100644
--- a/extensions/database/pom.xml
+++ b/extensions/database/pom.xml
@@ -105,7 +105,7 @@
     <dependency>
       <groupId>org.xerial</groupId>
       <artifactId>sqlite-jdbc</artifactId>
-      <version>3.44.1.0</version>
+      <version>3.45.0.0</version>
     </dependency>
     <dependency>
       <groupId>com.fasterxml.jackson.core</groupId>
diff --git a/extensions/gdata/pom.xml b/extensions/gdata/pom.xml
index 159794763..cc0ca6c03 100644
--- a/extensions/gdata/pom.xml
+++ b/extensions/gdata/pom.xml
@@ -84,7 +84,7 @@
     <dependency>
       <groupId>com.google.apis</groupId>
       <artifactId>google-api-services-drive</artifactId>
-      <version>v3-rev20240110-2.0.0</version>
+      <version>v3-rev20240123-2.0.0</version>
     </dependency>
     <dependency>
       <groupId>com.google.apis</groupId>
diff --git a/extensions/wikibase/module/scripts/preview-renderer.js b/extensions/wikibase/module/scripts/preview-renderer.js
index 9f61a6cf3..96bc6ddb9 100644
--- a/extensions/wikibase/module/scripts/preview-renderer.js
+++ b/extensions/wikibase/module/scripts/preview-renderer.js
@@ -238,7 +238,7 @@ EditRenderer._renderStatement = function(json, container) {
   var referencesToggle = $('').appendTo(referencesToggleContainer);
   right = $('

').addClass('wbs-right').appendTo(referencesSection);
   var referenceContainer = $('

').addClass('wbs-reference-container').appendTo(right);
-  referencesToggle.on('click',function () {
+  referencesToggleContainer.on('click',function () {
       triangle.toggleClass('pointing-down');
       triangle.toggleClass('pointing-right');
       referenceContainer.toggle(100);
diff --git a/extensions/wikibase/pom.xml b/extensions/wikibase/pom.xml
index 697c0dbf5..5a176bcaa 100644
--- a/extensions/wikibase/pom.xml
+++ b/extensions/wikibase/pom.xml
@@ -15,7 +15,7 @@
   </parent>

   <properties>
-    <wdtk.version>0.14.6</wdtk.version>
+    <wdtk.version>0.14.7</wdtk.version>
   </properties>

   <build>
diff --git a/main/src/com/google/refine/model/Column.java b/main/src/com/google/refine/model/Column.java
index 7c456cec6..7dd296055 100644
--- a/main/src/com/google/refine/model/Column.java
+++ b/main/src/com/google/refine/model/Column.java
@@ -55,6 +55,7 @@ public class Column {
     private ReconStats _reconStats;

     transient protected Map<String, Object> _precomputes;
+    private ReconConfig _sourceReconConfig;

     @JsonCreator
     public Column(
@@ -95,6 +96,17 @@ public class Column {
         return _reconConfig;
     }

+    @JsonProperty("sourceReconConfig")
+    public void setSourceReconConfig(ReconConfig sourceConfig) {
+        this._sourceReconConfig = sourceConfig;
+    }
+
+    @JsonProperty("sourceReconConfig")
+    @JsonInclude(Include.NON_NULL)
+    public ReconConfig getSourceReconConfig() {
+        return _sourceReconConfig;
+    }
+
     @JsonProperty("reconStats")
     public void setReconStats(ReconStats stats) {
         this._reconStats = stats;
diff --git a/main/src/com/google/refine/model/changes/DataExtensionChange.java b/main/src/com/google/refine/model/changes/DataExtensionChange.java
index 8adbcb9b7..1ba0366b5 100644
--- a/main/src/com/google/refine/model/changes/DataExtensionChange.java
+++ b/main/src/com/google/refine/model/changes/DataExtensionChange.java
@@ -220,6 +220,9 @@ public class DataExtensionChange implements Change {
                         _identifierSpace,
                         _schemaSpace,
                         columnType));
+                if (project.columnModel.getColumnByName(_baseColumnName) != null) {
+                    column.setSourceReconConfig(project.columnModel.getColumnByName(_baseColumnName).getReconConfig());
+                }
                 ReconStats reconStats = ReconStats.create(project, cellIndex);
                 if (reconStats.matchedTopics > 0) {
                     column.setReconStats(reconStats);
diff --git a/main/webapp/modules/core/langs/translation-ca.json b/main/webapp/modules/core/langs/translation-ca.json
index 78afd0111..ce642b217 100644
--- a/main/webapp/modules/core/langs/translation-ca.json
+++ b/main/webapp/modules/core/langs/translation-ca.json
@@ -859,5 +859,7 @@
     "core-views/column-join-before-column-picker": "Seleccioneu i ordeneu les columnes a unir",
     "core-views/column-join-before-options": "Selecciona les opcions",
     "core-views/column-join-field-separator": "Separador entre el contingut de cada columna",
-    "core-views/column-join-field-separator-advice": "Introduïu un o més caràcters, o manteniu-lo en blanc per a unir les columnes sense separador."
+    "core-views/column-join-field-separator-advice": "Introduïu un o més caràcters, o manteniu-lo en blanc per a unir les columnes sense separador.",
+    "core-views/see-more": "Veure més",
+    "core-views/see-less": "Veure menys"
 }
diff --git a/main/webapp/modules/core/langs/translation-en.json b/main/webapp/modules/core/langs/translation-en.json
index f07422f56..1c64feb83 100644
--- a/main/webapp/modules/core/langs/translation-en.json
+++ b/main/webapp/modules/core/langs/translation-en.json
@@ -526,6 +526,7 @@
     "core-views/warning-sel-judg": "Please select at least one kind of judgment to copy.",
     "core-views/start-recon": "Start reconciling…",
     "core-views/recon-text-fb": "Reconcile text in this column with items in another data source",
+    "core-views/data-extended-from" : "Data extended from $1",
     "core-views/facets": "Facets",
     "core-views/by-judg": "By judgment",
     "core-views/judgment": "$1: judgment",
diff --git a/main/webapp/modules/core/scripts/views/data-table/column-header-ui.js b/main/webapp/modules/core/scripts/views/data-table/column-header-ui.js
index 2356e9071..1c7f1191f 100644
--- a/main/webapp/modules/core/scripts/views/data-table/column-header-ui.js
+++ b/main/webapp/modules/core/scripts/views/data-table/column-header-ui.js
@@ -94,6 +94,18 @@ DataTableColumnHeaderUI.prototype._render = function() {
       .appendTo(whole);
     }
   }
+  if("sourceReconConfig" in this._column) {
+    if(this._column.sourceReconConfig.service){
+     var service = ReconciliationManager.getServiceFromUrl(this._column.sourceReconConfig.service);
+     var serviceName;
+     if(service) {
+       serviceName=service.name;
+     }
+     if(serviceName){
+      elmts.nameContainer.attr("title",$.i18n('core-views/data-extended-from',service.name));
+      }
+    }
+  }
 };

 DataTableColumnHeaderUI.prototype._createMenuForColumnHeader = function(elmt) {
diff --git a/pom.xml b/pom.xml
index e25b50f4e..4098e5c8d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -96,7 +96,7 @@
     <odfdom-java.version>0.12.0</odfdom-java.version>
     <vicino.version>1.2</vicino.version>
     <clojure.version>1.11.1</clojure.version>
-    <httpclient5.version>5.3</httpclient5.version>
+    <httpclient5.version>5.3.1</httpclient5.version>
     <httpcore.version>4.4.16</httpcore.version>
     <univocity-csv.version>2.9.1</univocity-csv.version>
     <swc-parser-lazy.version>3.1.9</swc-parser-lazy.version>
@@ -108,7 +108,7 @@
     <httpmime.version>4.5.14</httpmime.version>
     <owasp-encoder.version>1.2.3</owasp-encoder.version>
     <jasypt.version>1.9.3</jasypt.version>
-    <mockito.version>5.9.0</mockito.version>
+    <mockito.version>5.10.0</mockito.version>

     <build-helper-maven-plugin.version>3.5.0</build-helper-maven-plugin.version>
@@ -123,7 +123,7 @@
     <maven-clean-plugin.version>3.3.2</maven-clean-plugin.version>
     <git-commit-id-plugin.version>4.9.10</git-commit-id-plugin.version>
     <maven-deploy-plugin.version>3.1.1</maven-deploy-plugin.version>
-    <license-plugin.version>2.3.0</license-plugin.version>
+    <license-plugin.version>2.4.0</license-plugin.version>
     <org-jvnet-localizer-plugin.version>1.31</org-jvnet-localizer-plugin.version>
   </properties>

diff --git a/main/tests/server/src/com/google/refine/model/ColumnTests.java b/main/tests/server/src/com/google/refine/model/ColumnTests.java
index 0d34c1cef..7aa7c2523 100644
--- a/main/tests/server/src/com/google/refine/model/ColumnTests.java
+++ b/main/tests/server/src/com/google/refine/model/ColumnTests.java
@@ -60,6 +60,19 @@ public class ColumnTests {
                 + "      {\"column\":\"organization_id\",\"propertyName\":\"SPARQL: P3500|P2427\",\"propertyID\":\"P3500|P2427\"}"
                 + "    ],"
                 + "    \"limit\":0},"
+                + "\"sourceReconConfig\":{"
+                + "   \"mode\":\"standard-service\","
+                + "   \"service\":\"https://tools.wmflabs.org/openrefine-wikidata/en/api\","
+                + "   \"identifierSpace\":\"http://www.wikidata.org/entity/\","
+                + "   \"schemaSpace\":\"http://www.wikidata.org/prop/direct/\","
+                + "   \"type\":{\"id\":\"Q43229\",\"name\":\"organization\"},"
+                + "   \"autoMatch\":true,"
+                + "   \"batchSize\":10,"
+                + "   \"columnDetails\":["
+                + "      {\"column\":\"organization_country\",\"propertyName\":\"SPARQL: P17/P297\",\"propertyID\":\"P17/P297\"},"
+                + "      {\"column\":\"organization_id\",\"propertyName\":\"SPARQL: P3500|P2427\",\"propertyID\":\"P3500|P2427\"}"
+                + "    ],"
+                + "    \"limit\":0},"
                 + "\"reconStats\":{"
                 + "    \"nonBlanks\":299,"
                 + "    \"newTopics\":0,"
diff --git a/main/tests/server/src/com/google/refine/operations/recon/ExtendDataOperationTests.java b/main/tests/server/src/com/google/refine/operations/recon/ExtendDataOperationTests.java
index b7e191ba0..b3067160d 100644
--- a/main/tests/server/src/com/google/refine/operations/recon/ExtendDataOperationTests.java
+++ b/main/tests/server/src/com/google/refine/operations/recon/ExtendDataOperationTests.java
@@ -68,6 +68,7 @@ import com.google.refine.model.Project;
 import com.google.refine.model.Recon;
 import com.google.refine.model.ReconCandidate;
 import com.google.refine.model.Row;
+import com.google.refine.model.recon.DataExtensionReconConfig;
 import com.google.refine.model.recon.ReconciledDataExtensionJob;
 import com.google.refine.model.recon.ReconciledDataExtensionJob.DataExtensionConfig;
 import com.google.refine.operations.EngineDependentOperation;
@@ -154,6 +155,9 @@ public class ExtendDataOperationTests extends RefineTest {
     public void SetUp() throws IOException, ModelException {
         OperationRegistry.registerOperation(getCoreModule(), "extend-reconciled-data", ExtendDataOperation.class);
         project = createProjectWithColumns("DataExtensionTests", "country");
+        project.columnModel.getColumnByName("country").setReconConfig(new DataExtensionReconConfig(RECON_SERVICE,
+                RECON_IDENTIFIER_SPACE,
+                RECON_SCHEMA_SPACE, null));

         options = mock(Properties.class);
         engine = new Engine(project);
@@ -284,6 +288,9 @@ public class ExtendDataOperationTests extends RefineTest {

             // Make sure we did not create any recon stats for that column (no reconciled value)
             Assert.assertTrue(project.columnModel.getColumnByName("ISO 3166-1 alpha-2 code").getReconStats() == null);
+            // adding an assertion for sourceReconConfig
+            Assert.assertTrue(project.columnModel.getColumnByName("ISO 3166-1 alpha-2 code").getSourceReconConfig() != null);
+
         }
     }

Fixes #6304

Changes proposed in this pull request:

In this pull request, I have fixed the bug in which the referenceContainer was not unfolding on clicking of the triangle icon.

PFA Evidence.

https://github.com/OpenRefine/OpenRefine/assets/37299522/841f28db-7f15-4ea2-b07e-5d390574def2


diff --git a/main/src/com/google/refine/operations/row/RowAdditionOperation.java b/main/src/com/google/refine/operations/row/RowAdditionOperation.java
index c7e3bc7d9..d829c5f90 100644
--- a/main/src/com/google/refine/operations/row/RowAdditionOperation.java
+++ b/main/src/com/google/refine/operations/row/RowAdditionOperation.java
@@ -28,6 +28,7 @@
 package com.google.refine.operations.row;

 import java.util.List;
+import java.util.stream.Collectors;

 import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonProperty;
@@ -44,15 +45,39 @@ public class RowAdditionOperation extends AbstractOperation {
     final private List<Row> _rows;
     final private int _insertionIndex;

+    public RowAdditionOperation(
+            List<Row> rows,
+            int insertionIndex) {
+        _rows = rows;
+        _insertionIndex = insertionIndex;
+    }
+
+    /**
+     * Deserialization constructor to provide compatibility for the legacy serialization format. In this format, only
+     * the number of rows is relevant: the contents of the rows must be ignored, because they might have been corrupted
+     * due to mutability issues in https://github.com/OpenRefine/OpenRefine/issues/7245.
+     *
+     * @param addedRows
+     *            the rows to add to the project
+     * @param rows
+     *            a legacy serialization field, whose length is is the only thing that matters. If provided, it will be
+     *            converted to a list of empty rows of the same size.
+     * @param insertionIndex
+     *            the place in the grid where to insert this list.
+     * @deprecated should not be called directly, is only provided for JSON deserialization.
+     */
+    @Deprecated
     @JsonCreator
     public RowAdditionOperation(
-            @JsonProperty("rows") List<Row> rows,
+            @JsonProperty("addedRows") List<Row> addedRows,
+            @JsonProperty("rows") List<Object> rows,
             @JsonProperty("insertionIndex") int insertionIndex) {
-        _rows = rows;
+        _rows = addedRows != null ? addedRows
+                : (rows == null ? List.of() : rows.stream().map(r -> new Row(0)).collect(Collectors.toList()));
         _insertionIndex = insertionIndex;
     }

-    @JsonProperty("rows")
+    @JsonProperty("addedRows")
     public List<Row> getRows() {
         return _rows;
     }
diff --git a/modules/core/src/main/java/com/google/refine/model/Cell.java b/modules/core/src/main/java/com/google/refine/model/Cell.java
index 119f90e0d..084452272 100644
--- a/modules/core/src/main/java/com/google/refine/model/Cell.java
+++ b/modules/core/src/main/java/com/google/refine/model/Cell.java
@@ -193,6 +193,13 @@ public class Cell implements HasFields, Serializable {
         return new Cell((Serializable) value, recon);
     }

+    /**
+     * Return a deep copy of this object, making sure that if one instance is modified, the other isn't.
+     */
+    public Cell deepCopy() {
+        return new Cell(value, recon == null ? null : recon.dup());
+    }
+
     @Override
     public String toString() {
         // TODO this is kept like this for now, but it should rather be a string which makes the difference
diff --git a/modules/core/src/main/java/com/google/refine/model/Row.java b/modules/core/src/main/java/com/google/refine/model/Row.java
index 2aff00999..5550ec9fa 100644
--- a/modules/core/src/main/java/com/google/refine/model/Row.java
+++ b/modules/core/src/main/java/com/google/refine/model/Row.java
@@ -38,6 +38,7 @@ import java.io.Writer;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Properties;
+import java.util.stream.Collectors;

 import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonIgnore;
@@ -93,6 +94,19 @@ public class Row implements HasFields {
         return row;
     }

+    /**
+     * Make a deep copy of the row, making sure that if one is modified, the other isn't.
+     */
+    public Row deepCopy() {
+        Row row = new Row(cells.size());
+        row.flagged = flagged;
+        row.starred = starred;
+        row.cells.addAll(cells.stream()
+                .map(cell -> cell == null ? null : cell.deepCopy())
+                .collect(Collectors.toList()));
+        return row;
+    }
+
     @Override
     public Object getField(String name, Properties bindings) {
         if (FLAGGED.equals(name)) {
diff --git a/modules/core/src/main/java/com/google/refine/model/changes/RowAdditionChange.java b/modules/core/src/main/java/com/google/refine/model/changes/RowAdditionChange.java
index f3efb0f9c..5671d27e6 100644
--- a/modules/core/src/main/java/com/google/refine/model/changes/RowAdditionChange.java
+++ b/modules/core/src/main/java/com/google/refine/model/changes/RowAdditionChange.java
@@ -33,6 +33,7 @@ import java.io.Writer;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Properties;
+import java.util.stream.Collectors;

 import com.google.refine.ProjectManager;
 import com.google.refine.history.Change;
@@ -57,7 +58,7 @@ public class RowAdditionChange implements Change {
     @Override
     public void apply(Project project) {
         synchronized (project) {
-            project.rows.addAll(_insertionIndex, _additionalRows);
+            project.rows.addAll(_insertionIndex, _additionalRows.stream().map(row -> row.deepCopy()).collect(Collectors.toList()));

             project.update();
             project.columnModel.clearPrecomputes();

diff --git a/main/tests/server/src/com/google/refine/operations/row/RowAdditionOperationTests.java b/main/tests/server/src/com/google/refine/operations/row/RowAdditionOperationTests.java
index d029e0121..bbc3658cb 100644
--- a/main/tests/server/src/com/google/refine/operations/row/RowAdditionOperationTests.java
+++ b/main/tests/server/src/com/google/refine/operations/row/RowAdditionOperationTests.java
@@ -30,6 +30,7 @@ package com.google.refine.operations.row;
 import static org.testng.Assert.assertEquals;

 import java.io.IOException;
+import java.io.Serializable;
 import java.util.ArrayList;
 import java.util.List;

@@ -43,6 +44,8 @@ import org.testng.annotations.BeforeTest;
 import org.testng.annotations.Test;

 import com.google.refine.RefineTest;
+import com.google.refine.model.Cell;
+import com.google.refine.model.Project;
 import com.google.refine.model.Row;
 import com.google.refine.operations.OperationDescription;
 import com.google.refine.operations.OperationRegistry;
@@ -51,7 +54,8 @@ import com.google.refine.util.TestUtils;

 public class RowAdditionOperationTests extends RefineTest {

-    String json;
+    String legacyJson;
+    String newJson;

     @BeforeSuite
     public void registerOperation() {
@@ -60,9 +64,17 @@ public class RowAdditionOperationTests extends RefineTest {

     @BeforeMethod
     public void setUp() {
-        json = "{"
+        // JSON serialization before 3.9.3, which might contain some corrupted cells because of mutability issues,
+        // manifesting themselves in https://github.com/OpenRefine/OpenRefine/issues/7245
+        legacyJson = "{"
                 + "\"op\":\"core/row-addition\","
-                + "\"rows\":[{\"starred\":false,\"flagged\":false,\"cells\":[]},{\"starred\":false,\"flagged\":false,\"cells\":[]}],"
+                + "\"rows\":[{\"starred\":false,\"flagged\":false,\"cells\":[{\"foo\":3}]},{\"starred\":false,\"flagged\":false,\"cells\":[]}],"
+                + "\"index\":0,"
+                + "\"description\":" + new TextNode(OperationDescription.row_addition_brief()).toString() + "}";
+        // JSON serialization from 3.9.3 on
+        newJson = "{"
+                + "\"op\":\"core/row-addition\","
+                + "\"addedRows\":[{\"starred\":false,\"flagged\":false,\"cells\":[]},{\"starred\":false,\"flagged\":false,\"cells\":[]}],"
                 + "\"index\":0,"
                 + "\"description\":" + new TextNode(OperationDescription.row_addition_brief()).toString() + "}";
     }
@@ -74,9 +86,15 @@ public class RowAdditionOperationTests extends RefineTest {
     }

     @Test
-    public void testDeserialization() throws IOException {
-        RowAdditionOperation op = ParsingUtilities.mapper.readValue(json, RowAdditionOperation.class);
-        TestUtils.isSerializedTo(op, json);
+    public void testLegacyDeserialization() throws IOException {
+        RowAdditionOperation op = ParsingUtilities.mapper.readValue(legacyJson, RowAdditionOperation.class);
+        TestUtils.isSerializedTo(op, newJson);
+    }
+
+    @Test
+    public void testNewDeserialization() throws IOException {
+        RowAdditionOperation op = ParsingUtilities.mapper.readValue(newJson, RowAdditionOperation.class);
+        TestUtils.isSerializedTo(op, newJson);
     }

     @Test
@@ -89,7 +107,36 @@ public class RowAdditionOperationTests extends RefineTest {
         RowAdditionOperation op = new RowAdditionOperation(rows, index);
         ObjectMapper objectMapper = new ObjectMapper();
         String serializedObject = objectMapper.writeValueAsString(op);
-        assertEquals(serializedObject, json);
+        assertEquals(serializedObject, newJson);
     }

+
+    // regression test for https://github.com/OpenRefine/OpenRefine/issues/7245
+    @Test
+    public void mutabilityBugRegressionTest() throws Exception {
+        Project project = createProject(new String[] { "foo", "bar" },
+                new Serializable[][] {
+                        { "a", "b " },
+                        { null, "c" },
+                });
+
+        RowAdditionOperation op = ParsingUtilities.mapper.readValue(newJson, RowAdditionOperation.class);
+
+        // add the two rows at the beginning
+        runOperation(op, project);
+        // and then edit a cell in the first row
+        project.rows.get(0).setCell(0, new Cell("hello", null));
+
+        Project expected = createProject(new String[] { "foo", "bar" },
+                new Serializable[][] {
+                        { "hello", null },
+                        { null, null },
+                        { "a", "b " },
+                        { null, "c" },
+                });
+        assertProjectEquals(project, expected);
+
+        // the operation metadata is still unchanged
+        TestUtils.isSerializedTo(op, newJson);
+    }
 }
diff --git a/modules/core/src/test/java/com/google/refine/model/CellTests.java b/modules/core/src/test/java/com/google/refine/model/CellTests.java
index 1fecf9e1e..b2048df01 100644
--- a/modules/core/src/test/java/com/google/refine/model/CellTests.java
+++ b/modules/core/src/test/java/com/google/refine/model/CellTests.java
@@ -184,4 +184,17 @@ public class CellTests {
         assertNotEquals(c1, "foo");
         assertEquals(c1.hashCode(), new Cell("foo", recon).hashCode());
     }
+
+    @Test
+    public void testDeepCopy() {
+        Recon recon = mock(Recon.class);
+        Recon reconDup = mock(Recon.class);
+        when(recon.dup()).thenReturn(reconDup);
+
+        Cell c1 = new Cell("foo", recon);
+        Cell c2 = c1.deepCopy();
+
+        assertEquals(c2.value, "foo");
+        assertEquals(c2.recon, reconDup);
+    }
 }
diff --git a/modules/core/src/test/java/com/google/refine/model/RowTests.java b/modules/core/src/test/java/com/google/refine/model/RowTests.java
index 8337acd1b..c37662ef6 100644
--- a/modules/core/src/test/java/com/google/refine/model/RowTests.java
+++ b/modules/core/src/test/java/com/google/refine/model/RowTests.java
@@ -35,6 +35,9 @@ package com.google.refine.model;

 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;

 import java.io.StringWriter;
 import java.util.Properties;
@@ -187,4 +190,18 @@ public class RowTests extends RefineTest {
         Assert.assertTrue((Boolean) row.getField("starred", options));
     }

+    @Test
+    public void deepCopy() {
+        Row row = new Row(1);
+        row.flagged = true;
+        row.setCell(0, new Cell("original value", null));
+
+        Row copied = row.deepCopy();
+        row.starred = true;
+        row.setCell(0, new Cell("new value", null));
+
+        assertTrue(copied.flagged);
+        assertFalse(copied.starred);
+        assertEquals(copied.cells.get(0).value, "original value");
+    }
 }
diff --git a/modules/core/src/test/java/com/google/refine/model/changes/RowAdditionChangeTests.java b/modules/core/src/test/java/com/google/refine/model/changes/RowAdditionChangeTests.java
index b742aa489..3d6a48ffd 100644
--- a/modules/core/src/test/java/com/google/refine/model/changes/RowAdditionChangeTests.java
+++ b/modules/core/src/test/java/com/google/refine/model/changes/RowAdditionChangeTests.java
@@ -29,7 +29,7 @@ package com.google.refine.model.changes;

 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertNotNull;
-import static org.testng.Assert.assertSame;
+import static org.testng.Assert.assertNotSame;
 import static org.testng.Assert.assertTrue;

 import java.io.IOException;
@@ -107,13 +107,18 @@ public class RowAdditionChangeTests extends RefineTest {
     }

     @Test
-    // After prepend apply, project's new prepended rows are identical those passed to constructor
+    // After prepend apply, project's new prepended rows are equal those passed to constructor
+    // but not identical, because otherwise modifying the row will change the operation's metadata.
+    // See https://github.com/OpenRefine/OpenRefine/issues/7245
     public void testPrependApplyRowIdentity() {
         change.apply(project);
         for (int i = insertionIndex; i < newRows.size(); i++) {
             Row actual = project.rows.get(i);
             Row expected = newRows.get(i);
-            assertSame(actual, expected);
+            assertNotSame(actual, expected);
+            assertEquals(actual.flagged, expected.flagged);
+            assertEquals(actual.starred, expected.starred);
+            assertEquals(actual.cells, expected.cells);
         }
     }

@@ -205,6 +210,8 @@ public class RowAdditionChangeTests extends RefineTest {

     @Test
     // After append apply, project's new append rows are identical those passed to constructor
+    // but not identical, because otherwise modifying the row will change the operation's metadata.
+    // See https://github.com/OpenRefine/OpenRefine/issues/7245
     public void testAppendApplyRowIdentity() {
         insertionIndex = project.rows.size();
         change = new RowAdditionChange(newRows, insertionIndex);
@@ -213,7 +220,10 @@ public class RowAdditionChangeTests extends RefineTest {
         for (int i = insertionIndex; i < newRows.size(); i++) {
             Row actual = project.rows.get(insertionIndex + i);
             Row expected = newRows.get(i);
-            assertSame(actual, expected);
+            assertNotSame(actual, expected);
+            assertEquals(actual.flagged, expected.flagged);
+            assertEquals(actual.starred, expected.starred);
+            assertEquals(actual.cells, expected.cells);
         }
     }

Backport: Fix deserialization of RowAdditionOperation