fix(fs): support static async realpath

- add fs/promises.realpath with Node-shaped rejections
- resolve Windows links to canonical paths
- cover behavior and refresh compatibility evidence

Fixes #371

Co-authored-by: Hagege Ruben <20857346+HagegeR@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-09-22 18:27:15 -05:00
co-authored by Hagege Ruben
parent c9cad7a0ba
commit 1ecfdcdc04
16 changed files with 160 additions and 33 deletions
@@ -2,6 +2,6 @@
"schemaVersion": 3,
"nodeVersion": "24.15.0",
"nodeCommit": "848430679556aed0bd073f2bc263331ad84fa119",
"artifactVersion": "80b3d2a5ad0d17bd8d8f",
"artifactVersion": "85585dd4e87de9edca6c",
"rowCount": 3662
}
@@ -458,9 +458,9 @@
"apiEntries": 276,
"static": {
"supported": 0,
"partial": 39,
"partial": 40,
"refused": 0,
"not-implemented": 237,
"not-implemented": 236,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
@@ -36385,9 +36385,9 @@
"inherited": true
},
"static": {
"status": "not-implemented",
"detail": "No static compiler lowering is registered for this API yet.",
"verification": "registry-gap"
"status": "partial",
"detail": "Implemented for the documented scriptc module-loader subset.",
"verification": "test-backed"
},
"dynamic": {
"status": "partial",
@@ -8,8 +8,8 @@
"tiers": {
"static": {
"replace-refusal": 46,
"verify-gap": 2711,
"audit-partial": 455,
"verify-gap": 2710,
"audit-partial": 456,
"classify": 2,
"implement": 81
},
@@ -35463,12 +35463,14 @@
"priority": "high",
"tiers": {
"static": {
"status": "not-implemented",
"action": "verify-gap",
"verification": "registry-gap",
"confidence": "low",
"source": "compiler-unmatched:fs",
"tests": []
"status": "partial",
"action": "audit-partial",
"verification": "test-backed",
"confidence": "high",
"source": "compiler-feature:fs.fsPromises.realpath",
"tests": [
"tests/corpus/2940-fsp-realpath.ts"
]
},
"dynamic": {
"status": "partial",
@@ -507,9 +507,9 @@
"apiEntries": 276,
"static": {
"supported": 0,
"partial": 39,
"partial": 40,
"refused": 0,
"not-implemented": 237,
"not-implemented": 236,
"by-design": 0,
"unreviewed": 0,
"not-applicable": 0
@@ -50572,7 +50572,8 @@
"tests/corpus/2595-fs-arg-ladders.cjs",
"tests/corpus/2844-node-disposable-resources.ts",
"tests/corpus/2919-fs-function-values.ts",
"tests/corpus/2920-fs-promises-function-values.ts"
"tests/corpus/2920-fs-promises-function-values.ts",
"tests/corpus/2940-fsp-realpath.ts"
]
},
"dynamic": {
@@ -52042,8 +52043,11 @@
"inherited": true
},
"static": {
"status": "not-implemented",
"evidence": "compiler-unmatched:fs"
"status": "partial",
"evidence": "compiler-feature:fs.fsPromises.realpath",
"tests": [
"tests/corpus/2940-fsp-realpath.ts"
]
},
"dynamic": {
"status": "partial",
@@ -97,6 +97,7 @@
{ "chapter": "child_process", "signatures": ["`stdout` Type: {stream.Readable|null|undefined}", "`stderr` Type: {stream.Readable|null|undefined}"], "status": "partial", "evidence": ["tests/corpus/1565-spawn-pipe-streams.ts", "tests/corpus/2939-child-shell-text.ts"] },
{ "chapter": "child_process", "signatures": ["`stdin` Type: {stream.Writable|null|undefined}"], "status": "partial", "evidence": ["tests/corpus/2893-child-stdin-duplex.ts", "tests/corpus/2894-child-stdin-backpressure.ts", "tests/corpus/2895-child-stdin-write-after-end.ts", "tests/corpus/2896-child-stdin-write-after-finish.ts", "tests/corpus/2897-child-stdin-destroy.ts", "tests/corpus/2898-child-stdin-spawn-failure.ts"] },
{ "chapter": "fs", "signatures": ["`filehandle[Symbol.asyncDispose]()`"], "status": "partial", "evidence": ["tests/corpus/2844-node-disposable-resources.ts"] },
{ "chapter": "fs", "signatures": ["`fsPromises.realpath(path[, options])`"], "status": "partial", "evidence": ["tests/corpus/2940-fsp-realpath.ts"] },
{ "chapter": "readline", "signatures": ["`rl[Symbol.dispose]()`"], "status": "partial", "evidence": ["tests/corpus/2844-node-disposable-resources.ts"] },
{ "chapter": "timers", "signatures": ["`immediate[Symbol.dispose]()`", "`timeout[Symbol.dispose]()`"], "status": "partial", "evidence": ["tests/corpus/2844-node-disposable-resources.ts"] },
{ "chapter": "stream", "signatures": ["`readable[Symbol.asyncIterator]()`", "`readable.iterator([options])`"], "status": "partial", "evidence": ["tests/corpus/1746-stream-for-await.ts", "tests/corpus/2861-readable-iterator-close.ts"] },
+1
View File
@@ -1225,6 +1225,7 @@ declare module "fs/promises" {
export function readdir(path: string): Promise<string[]>;
export function rm(path: string): Promise<void>;
export function stat(path: string): Promise<import("node:fs").Stats>;
export function realpath(path: string): Promise<string>;
export function unlink(path: string): Promise<void>;
export function chmod(path: string, mode: number): Promise<void>;
export function rename(oldPath: string, newPath: string): Promise<void>;
+2
View File
@@ -4667,6 +4667,8 @@ function emitFilesystemLibCall(state: LibCallState): Temp {
return finish(`scr_fsp_rm(${arg(0)})`);
case "fsp.stat":
return finish(`scr_fsp_stat(${arg(0)})`);
case "fsp.realpath":
return finish(`scr_fsp_realpath(${arg(0)})`);
case "fsp.open":
return finish(`scr_fsp_open(${arg(0)}, ${arg(1)}, ${arg(2)})`);
case "fileHandle.fd":
@@ -397,6 +397,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"fsp.readdir": "scr_fsp_readdir",
"fsp.rm": "scr_fsp_rm",
"fsp.stat": "scr_fsp_stat",
"fsp.realpath": "scr_fsp_realpath",
"fsp.open": "scr_fsp_open",
"fileHandle.fd": "scr_file_handle_fd",
"fileHandle.close": "scr_file_handle_close_promise",
@@ -696,6 +696,7 @@ export const BUILTIN_MODULE_FNS: Record<string, Record<string, BuiltinModuleFn |
readdir: { fn: "fsp.readdir", params: [STRING], result: { kind: "promise", inner: arrayOf(STRING) } },
rm: { fn: "fsp.rm", params: [STRING], result: { kind: "promise", inner: VOID } },
stat: { fn: "fsp.stat", params: [STRING], result: { kind: "promise", inner: STATS_T } },
realpath: { fn: "fsp.realpath", params: [STRING], result: { kind: "promise", inner: STRING } },
unlink: { fn: "fsp.unlink", params: [STRING], result: { kind: "promise", inner: VOID }, valueParams: exactValueParams(STRING) },
chmod: { fn: "fsp.chmod", params: [STRING, F64], result: { kind: "promise", inner: VOID }, valueParams: exactValueParams(STRING, F64) },
rename: { fn: "fsp.rename", params: [STRING, STRING], result: { kind: "promise", inner: VOID }, valueParams: exactValueParams(STRING, STRING) },
+1
View File
@@ -3308,6 +3308,7 @@ export type IrLibFn =
| "fsp.readdir"
| "fsp.rm"
| "fsp.stat"
| "fsp.realpath"
/** fs/promises.open and the statically represented FileHandle surface.
* Every operation returns an already-settled promise; syscall failures
* become rejections rather than escaping synchronously. read/write
+1
View File
@@ -874,6 +874,7 @@ export const LIB_FN_SIGS: Record<IrLibFn, { argTypes: (IrType | null)[]; result:
"fsp.readdir": { argTypes: [STRING], result: { kind: "promise", inner: arrayOf(STRING) } },
"fsp.rm": { argTypes: [STRING], result: { kind: "promise", inner: VOID } },
"fsp.stat": { argTypes: [STRING], result: { kind: "promise", inner: STATS_T } },
"fsp.realpath": { argTypes: [STRING], result: { kind: "promise", inner: STRING } },
"fsp.open": { argTypes: [STRING, STRING, F64], result: { kind: "promise", inner: FILEHANDLE_T } },
"fileHandle.fd": { argTypes: [FILEHANDLE_T], result: F64 },
"fileHandle.close": { argTypes: [FILEHANDLE_T], result: { kind: "promise", inner: VOID } },
+6
View File
@@ -966,6 +966,12 @@
"name": "fs/promises.readdir",
"status": "static"
},
{
"id": "node-builtin.fs.promises.realpath",
"kind": "node-builtin",
"name": "fs/promises.realpath",
"status": "static"
},
{
"id": "node-builtin.fs.promises.rename",
"kind": "node-builtin",
+4
View File
@@ -1657,6 +1657,10 @@ ScrPromise *scr_fsp_stat(ScrStr *path) {
return scr_promise_settled_ref(st, &scr_stats_retain_v, &scr_stats_release_v, NULL);
}
ScrPromise *scr_fsp_realpath(ScrStr *path) {
return scr_promise_settled_str(scr_fs_realpath_promise(path));
}
ScrPromise *scr_fsp_rename(ScrStr *oldpath, ScrStr *newpath) {
scr_fs_rename(oldpath, newpath);
return scr_promise_settled_void();
+74 -14
View File
@@ -1852,34 +1852,94 @@ ScrStr *scr_fs_read_file(ScrStr *path) {
return s;
}
ScrStr *scr_fs_realpath(ScrStr *path) {
#ifdef _WIN32
/* _fullpath resolves . / .. and drive-relative forms (symlink-free —
* the honest Windows approximation); a missing path throws Node's
* lstat-spelled ENOENT like the POSIX arm. */
char buf[PATH_MAX];
if (_fullpath(buf, path->data, sizeof buf) == NULL) {
scr_fs_throw(errno ? errno : ENOENT, "lstat", path);
static WCHAR *scr_fs_win_wide(const ScrStr *path);
static int scr_fs_win_errno(DWORD error);
#endif
static ScrStr *scr_fs_realpath_common(ScrStr *path, const char *op) {
#ifdef _WIN32
WCHAR *wide = scr_fs_win_wide(path);
if (!wide) {
scr_fs_throw(scr_fs_win_errno(GetLastError()), op, path);
return NULL;
}
if (GetFileAttributesA(buf) == INVALID_FILE_ATTRIBUTES) {
scr_fs_throw(ENOENT, "lstat", path);
HANDLE handle = CreateFileW(wide, FILE_READ_ATTRIBUTES,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL);
free(wide);
if (handle == INVALID_HANDLE_VALUE) {
scr_fs_throw(scr_fs_win_errno(GetLastError()), op, path);
return NULL;
}
return scr_str_new(buf, strlen(buf));
DWORD needed = GetFinalPathNameByHandleW(handle, NULL, 0, FILE_NAME_NORMALIZED | VOLUME_NAME_DOS);
if (needed == 0) {
DWORD error = GetLastError();
CloseHandle(handle);
scr_fs_throw(scr_fs_win_errno(error), op, path);
return NULL;
}
WCHAR *resolved = malloc(((size_t)needed + 1) * sizeof *resolved);
if (!resolved) {
CloseHandle(handle);
scr_trap("scriptc: out of memory\n");
}
DWORD length = GetFinalPathNameByHandleW(handle, resolved, needed + 1,
FILE_NAME_NORMALIZED | VOLUME_NAME_DOS);
DWORD error = length == 0 ? GetLastError() : length > needed ? ERROR_INSUFFICIENT_BUFFER : ERROR_SUCCESS;
CloseHandle(handle);
if (error != ERROR_SUCCESS) {
free(resolved);
scr_fs_throw(scr_fs_win_errno(error), op, path);
return NULL;
}
size_t prefix = 0;
bool unc = false;
if (length >= 8 && wcsncmp(resolved, L"\\\\?\\UNC\\", 8) == 0) {
prefix = 8;
unc = true;
} else if (length >= 4 && wcsncmp(resolved, L"\\\\?\\", 4) == 0) {
prefix = 4;
}
int utf8_len = WideCharToMultiByte(CP_UTF8, 0, resolved + prefix,
(int)(length - prefix), NULL, 0, NULL, NULL);
if (utf8_len <= 0) {
error = GetLastError();
free(resolved);
scr_fs_throw(scr_fs_win_errno(error), op, path);
return NULL;
}
size_t extra = unc ? 2 : 0;
char *utf8 = malloc(extra + (size_t)utf8_len);
if (!utf8) {
free(resolved);
scr_trap("scriptc: out of memory\n");
}
if (unc) { utf8[0] = '\\'; utf8[1] = '\\'; }
(void)WideCharToMultiByte(CP_UTF8, 0, resolved + prefix, (int)(length - prefix),
utf8 + extra, utf8_len, NULL, NULL);
ScrStr *result = scr_str_new(utf8, extra + (size_t)utf8_len);
free(utf8);
free(resolved);
return result;
#else
/* realpath(3); Node's realpathSync reports failures with the "lstat"
* syscall in the message ("ENOENT: no such file or directory, lstat
* 'x'") — its own resolution walks lstat by component. */
char buf[PATH_MAX];
if (realpath(path->data, buf) == NULL) {
scr_fs_throw(errno, "lstat", path);
scr_fs_throw(errno, op, path);
return NULL;
}
return scr_str_new(buf, strlen(buf));
#endif
}
ScrStr *scr_fs_realpath(ScrStr *path) {
return scr_fs_realpath_common(path, "lstat");
}
ScrStr *scr_fs_realpath_promise(ScrStr *path) {
return scr_fs_realpath_common(path, "realpath");
}
static void scr_fs_write_common(ScrStr *path, ScrStr *data, const char *mode) {
FILE *f = fopen(path->data, mode);
if (!f) {
+2
View File
@@ -2312,6 +2312,7 @@ ScrStr *scr_fs_read_file(ScrStr *path);
/* realpath(3) (+1 fresh); failures throw with Node's realpathSync
* spelling (syscall "lstat"). */
ScrStr *scr_fs_realpath(ScrStr *path);
ScrStr *scr_fs_realpath_promise(ScrStr *path);
void scr_fs_write_file(ScrStr *path, ScrStr *data);
void scr_fs_append_file(ScrStr *path, ScrStr *data);
bool scr_fs_exists(ScrStr *path);
@@ -2428,6 +2429,7 @@ ScrPromise *scr_fsp_chmod(ScrStr *path, double mode);
ScrPromise *scr_fsp_readdir(ScrStr *path);
ScrPromise *scr_fsp_rm(ScrStr *path);
ScrPromise *scr_fsp_stat(ScrStr *path);
ScrPromise *scr_fsp_realpath(ScrStr *path);
ScrPromise *scr_fsp_rename(ScrStr *oldpath, ScrStr *newpath);
ScrPromise *scr_fsp_open(ScrStr *path, ScrStr *flags, double mode);
ScrPromise *scr_file_handle_close_promise(ScrFileHandle *h);
+41
View File
@@ -0,0 +1,41 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import * as fs from "node:fs";
import { realpath } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
const dir = mkdtempSync(join(tmpdir(), "scr-realpath-"));
const target = join(dir, "target");
const link = join(dir, "link");
mkdirSync(target);
writeFileSync(join(target, "file.txt"), "content");
execFileSync(
"node",
[
"-e",
"require('node:fs').symlinkSync(process.argv[1],process.argv[2],process.platform==='win32'?'junction':'dir')",
target,
link,
],
{ stdio: "pipe" },
);
async function main(): Promise<void> {
try {
console.log("cwd", (await realpath(".")) === realpathSync("."));
console.log("link", (await realpath(join(link, "file.txt"))) === realpathSync(join(target, "file.txt")));
console.log("namespace", (await fs.promises.realpath(link)) === realpathSync(target));
try {
await realpath(join(dir, "missing"));
console.log("missing unexpectedly resolved");
} catch (e) {
const error = e as NodeJS.ErrnoException;
console.log("missing", error instanceof Error, error.code, error.message.includes("realpath"), error.message.includes("missing"));
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
main();