Repository: denoland/deno
Pull request: fix(ext/node): return first created path from recursive "node:fs" mkdir call

## Summary

- Fix `fs.mkdir()`, `fs.mkdirSync()`, and `fs/promises.mkdir()` with `{ recursive: true }` to return the first directory path created, matching Node.js behavior
- When all directories already exist, correctly returns `undefined`
- Enables `test-fs-mkdir.js` Node compat test

Closes #24165

Changed files:
filename: ext/node/polyfills/_fs/_fs_mkdir.ts
patch: @@ -5,12 +5,83 @@
 
 import type { CallbackWithError } from "ext:deno_node/_fs/_fs_common.ts";
 import { promisify } from "ext:deno_node/internal/util.mjs";
-import { denoErrorToNodeError } from "ext:deno_node/internal/errors.ts";
+import {
+  denoErrorToNodeError,
+  uvException,
+} from "ext:deno_node/internal/errors.ts";
 import { getValidatedPath } from "ext:deno_node/internal/fs/utils.mjs";
 import {
   parseFileMode,
   validateBoolean,
 } from "ext:deno_node/internal/validators.mjs";
+import { isWindows } from "ext:deno_node/_util/os.ts";
+import { codeMap } from "ext:deno_node/internal_binding/uv.ts";
+import { resolve, toNamespacedPath } from "node:path";
+
+type MkdirCallback =
+  | ((err: Error | null, path?: string) => void)
+  | CallbackWithError;
+
+/**
+ * On Windows, recursive mkdir through a file returns EEXIST instead of
+ * ENOTDIR. Check if any component of the path is a file and fix the error.
+ */
+function fixMkdirError(
+  err: Error,
+  path: string,
+): Error {
+  const nodeErr = denoErrorToNodeError(err, { syscall: "mkdir", path });
+  if (!isWindows) return nodeErr;
+  if ((nodeErr as NodeJS.ErrnoException).code !== "EEXIST") return nodeErr;
+  // Walk up parent components to check if any is a file (skip the target
+  // itself - if the target is a file, EEXIST is correct)
+  let cursor = resolve(path, "..");
+  while (true) {
+    try {
+      const stat = Deno.statSync(cursor);
+      if (!stat.isDirectory) {
+        return uvException({
+          errno: codeMap.get("ENOTDIR")!,
+          syscall: "mkdir",
+          path,
+        });
+      }
+      break;
+    } catch {
+      const parent = resolve(cursor, "..");
+      if (parent === cursor) break;
+      cursor = parent;
+    }
+  }
+  return nodeErr;
+}
+
+/** Find the first component of `path` that does not exist. */
+function findFirstNonExistent(path: string): string | undefined {
+  let cursor = resolve(path);
+  while (true) {
+    try {
+      Deno.statSync(cursor);
+      // cursor exists, so the first non-existent is one level deeper
+      return undefined;
+    } catch {
+      const parent = resolve(cursor, "..");
+      if (parent === cursor) {
+        // reached filesystem root - nothing exists
+        return toNamespacedPath(cursor);
+      }
+      // Check if the parent exists
+      try {
+        Deno.statSync(parent);
+        // parent exists but cursor doesn't - cursor is first non-existent
+        return toNamespacedPath(cursor);
+      } catch {
+        // parent also doesn't exist, keep going up
+        cursor = parent;
+      }
+    }
+  }
+}
 
 /**
  * TODO: Also accept 'path' parameter as a Node polyfill Buffer type once these
@@ -23,8 +94,8 @@ type MkdirOptions =
 
 export function mkdir(
   path: string | URL,
-  options?: MkdirOptions | CallbackWithError,
-  callback?: CallbackWithError,
+  options?: MkdirOptions | MkdirCallback,
+  callback?: MkdirCallback,
 ) {
   path = getValidatedPath(path) as string;
 
@@ -45,24 +116,43 @@ export function mkdir(
   }
   validateBoolean(recursive, "options.recursive");
 
+  let firstNonExistent: string | undefined;
+  try {
+    firstNonExistent = recursive ? findFirstNonExistent(path) : undefined;
+  } catch (err) {
+    if (typeof callback === "function") {
+      callback(
+        denoErrorToNodeError(err as Error, { syscall: "mkdir", path }),
+      );
+    }
+    return;
+  }
+
   Deno.mkdir(path, { recursive, mode })
     .then(() => {
       if (typeof callback === "function") {
-        callback(null);
+        callback(null, firstNonExistent);
       }
     }, (err) => {
       if (typeof callback === "function") {
-        callback(err);
+        callback(
+          recursive
+            ? fixMkdirError(err as Error, path as string)
+            : denoErrorToNodeError(err as Error, { syscall: "mkdir", path }),
+        );
       }
     });
 }
 
 export const mkdirPromise = promisify(mkdir) as (
   path: string | URL,
   options?: MkdirOptions,
-) => Promise<void>;
+) => Promise<string | undefined>;
 
-export function mkdirSync(path: string | URL, options?: MkdirOptions) {
+export function mkdirSync(
+  path: string | URL,
+  options?: MkdirOptions,
+): string | undefined {
   path = getValidatedPath(path) as string;
 
   let mode = 0o777;
@@ -80,9 +170,15 @@ export function mkdirSync(path: string | URL, options?: MkdirOptions) {
   }
   validateBoolean(recursive, "options.recursive");
 
+  let firstNonExistent: string | undefined;
   try {
+    firstNonExistent = recursive ? findFirstNonExistent(path) : undefined;
     Deno.mkdirSync(path, { recursive, mode });
   } catch (err) {
-    throw denoErrorToNodeError(err as Error, { syscall: "mkdir", path });
+    throw recursive
+      ? fixMkdirError(err as Error, path)
+      : denoErrorToNodeError(err as Error, { syscall: "mkdir", path });
   }
+
+  return firstNonExistent;
 }

filename: tests/node_compat/config.jsonc
patch: @@ -549,6 +549,7 @@
     "parallel/test-fs-long-path.js": {},
     "parallel/test-fs-make-callback.js": {},
     "parallel/test-fs-makeStatsCallback.js": {},
+    "parallel/test-fs-mkdir.js": {},
     "parallel/test-fs-mkdtemp-prefix-check.js": {},
     "parallel/test-fs-mkdtemp.js": {},
     "parallel/test-fs-open-flags.js": {},