@ttsc/wasm 0.19.3 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +4 -2
  2. package/build/build-wasm.cjs +131 -126
  3. package/dist/.ttsc-wasm-build.json +319 -0
  4. package/dist/go.mod +2 -0
  5. package/dist/ttsc.wasm +0 -0
  6. package/go.mod +2 -0
  7. package/host/fountain.go +48 -19
  8. package/lib/src/createMemFS.d.ts +6 -0
  9. package/lib/src/createMemFS.js +247 -95
  10. package/lib/src/createMemFS.js.map +1 -1
  11. package/lib/src/structures/ITtscApi.d.ts +8 -5
  12. package/lib/src/structures/ITtscNodeAtPositionResult.d.ts +1 -1
  13. package/lib/src/structures/ITtscNodeInfo.d.ts +1 -1
  14. package/lib/src/structures/ITtscPositionQuery.d.ts +4 -2
  15. package/lib/src/structures/ITtscSymbolAtPositionResult.d.ts +1 -1
  16. package/lib/src/structures/ITtscTypeAtPositionResult.d.ts +1 -1
  17. package/lib/src/structures/IWasmExecFS.d.ts +14 -4
  18. package/package.json +1 -1
  19. package/shim-vendor/shim/ast/shim.go +48 -3
  20. package/shim-vendor/shim/astnav/go.mod +14 -0
  21. package/shim-vendor/shim/astnav/go.sum +20 -0
  22. package/shim-vendor/shim/astnav/shim.go +15 -0
  23. package/shim-vendor/shim/checker/shim.go +100 -0
  24. package/shim-vendor/shim/core/shim.go +47 -0
  25. package/shim-vendor/shim/diagnosticwriter/lint.go +50 -0
  26. package/src/createMemFS.ts +264 -103
  27. package/src/structures/ITtscApi.ts +8 -5
  28. package/src/structures/ITtscNodeAtPositionResult.ts +1 -1
  29. package/src/structures/ITtscNodeInfo.ts +1 -1
  30. package/src/structures/ITtscPositionQuery.ts +4 -2
  31. package/src/structures/ITtscSymbolAtPositionResult.ts +1 -1
  32. package/src/structures/ITtscTypeAtPositionResult.ts +1 -1
  33. package/src/structures/IWasmExecFS.ts +14 -4
package/dist/go.mod CHANGED
@@ -8,6 +8,7 @@ go 1.26
8
8
  // public proxy cannot resolve the in-tree shim modules.
9
9
  replace (
10
10
  github.com/microsoft/typescript-go/shim/ast => ./shim-vendor/shim/ast
11
+ github.com/microsoft/typescript-go/shim/astnav => ./shim-vendor/shim/astnav
11
12
  github.com/microsoft/typescript-go/shim/bundled => ./shim-vendor/shim/bundled
12
13
  github.com/microsoft/typescript-go/shim/checker => ./shim-vendor/shim/checker
13
14
  github.com/microsoft/typescript-go/shim/compiler => ./shim-vendor/shim/compiler
@@ -26,6 +27,7 @@ replace (
26
27
 
27
28
  require (
28
29
  github.com/microsoft/typescript-go/shim/ast v0.0.0
30
+ github.com/microsoft/typescript-go/shim/astnav v0.0.0
29
31
  github.com/microsoft/typescript-go/shim/compiler v0.0.0
30
32
  github.com/microsoft/typescript-go/shim/scanner v0.0.0
31
33
  github.com/samchon/ttsc/packages/ttsc v0.0.0
package/dist/ttsc.wasm CHANGED
Binary file
package/go.mod CHANGED
@@ -8,6 +8,7 @@ go 1.26
8
8
  // public proxy cannot resolve the in-tree shim modules.
9
9
  replace (
10
10
  github.com/microsoft/typescript-go/shim/ast => ./shim-vendor/shim/ast
11
+ github.com/microsoft/typescript-go/shim/astnav => ./shim-vendor/shim/astnav
11
12
  github.com/microsoft/typescript-go/shim/bundled => ./shim-vendor/shim/bundled
12
13
  github.com/microsoft/typescript-go/shim/checker => ./shim-vendor/shim/checker
13
14
  github.com/microsoft/typescript-go/shim/compiler => ./shim-vendor/shim/compiler
@@ -26,6 +27,7 @@ replace (
26
27
 
27
28
  require (
28
29
  github.com/microsoft/typescript-go/shim/ast v0.0.0
30
+ github.com/microsoft/typescript-go/shim/astnav v0.0.0
29
31
  github.com/microsoft/typescript-go/shim/compiler v0.0.0
30
32
  github.com/microsoft/typescript-go/shim/scanner v0.0.0
31
33
  github.com/samchon/ttsc/packages/ttsc v0.0.0
package/host/fountain.go CHANGED
@@ -28,6 +28,7 @@ import (
28
28
  "syscall/js"
29
29
 
30
30
  "github.com/microsoft/typescript-go/shim/ast"
31
+ "github.com/microsoft/typescript-go/shim/astnav"
31
32
  shimscanner "github.com/microsoft/typescript-go/shim/scanner"
32
33
 
33
34
  "github.com/samchon/ttsc/packages/ttsc/driver"
@@ -291,28 +292,31 @@ func jsGetDiagnostics(this js.Value, args []js.Value) any {
291
292
  }
292
293
 
293
294
  // jsGetNodeAtPosition({handle, path, position}) → Promise<ITtscResult>.
294
- // `position` is a byte offset into the source text (the same coordinate the
295
- // AST uses internally). JS callers that have a UTF-16 line/character pair
296
- // should resolve it to a byte offset before calling (the @ttsc/playground
297
- // helper does this).
295
+ // Returns the token touching the byte offset, including punctuation.
296
+ // Whitespace and comments return `{node: null}`. JS callers with a UTF-16
297
+ // line/character pair must resolve it to a byte offset first.
298
298
  func jsGetNodeAtPosition(this js.Value, args []js.Value) any {
299
299
  return withSnapshotPosition(args, func(_ *snapshotEntry, file *ast.SourceFile, pos int) any {
300
- node := ast.GetNodeAtPosition(file, pos, false)
300
+ node := tokenAtPosition(file, pos)
301
301
  return fountainOK(GetNodeAtPositionResult{Node: nodeInfoOf(node)})
302
302
  })
303
303
  }
304
304
 
305
305
  // jsGetTypeAtPosition({handle, path, position}) → Promise<ITtscResult>.
306
- // Resolves the node at position via GetNodeAtPosition and asks the Program's
307
- // pinned single checker for its type. Returns `{type: null}` when there is no
308
- // node at that position or no checker is available.
306
+ // Resolves the touching token, then asks the Program's pinned single checker
307
+ // for the enclosing semantic AST node. Returns `{type: null}` when no token
308
+ // has type semantics, including whitespace, comments, and punctuation.
309
309
  func jsGetTypeAtPosition(this js.Value, args []js.Value) any {
310
310
  return withSnapshotPosition(args, func(entry *snapshotEntry, file *ast.SourceFile, pos int) any {
311
- node := ast.GetNodeAtPosition(file, pos, false)
312
- if node == nil || entry.prog.Checker == nil {
311
+ node := tokenAtPosition(file, pos)
312
+ if !hasTypeAtLocation(node) || entry.prog.Checker == nil {
313
+ return fountainOK(GetTypeAtPositionResult{Type: nil})
314
+ }
315
+ semanticNode := ast.GetNodeAtPosition(file, pos, false)
316
+ if semanticNode == nil {
313
317
  return fountainOK(GetTypeAtPositionResult{Type: nil})
314
318
  }
315
- t := entry.prog.Checker.GetTypeAtLocation(node)
319
+ t := entry.prog.Checker.GetTypeAtLocation(semanticNode)
316
320
  if t == nil {
317
321
  return fountainOK(GetTypeAtPositionResult{Type: nil})
318
322
  }
@@ -326,11 +330,11 @@ func jsGetTypeAtPosition(this js.Value, args []js.Value) any {
326
330
  }
327
331
 
328
332
  // jsGetSymbolAtPosition({handle, path, position}) → Promise<ITtscResult>.
329
- // Returns `{symbol: null}` when the node at position has no associated
330
- // symbol (e.g. punctuation, whitespace).
333
+ // Returns `{symbol: null}` when the touching token has no associated symbol,
334
+ // including punctuation, whitespace, and comments.
331
335
  func jsGetSymbolAtPosition(this js.Value, args []js.Value) any {
332
336
  return withSnapshotPosition(args, func(entry *snapshotEntry, file *ast.SourceFile, pos int) any {
333
- node := ast.GetNodeAtPosition(file, pos, false)
337
+ node := tokenAtPosition(file, pos)
334
338
  if node == nil || entry.prog.Checker == nil {
335
339
  return fountainOK(GetSymbolAtPositionResult{Symbol: nil})
336
340
  }
@@ -374,9 +378,9 @@ func withSnapshot(args []js.Value, fn func(*snapshotEntry, js.Value) any) any {
374
378
 
375
379
  // withSnapshotPosition is the shared envelope for the 3 position-bound
376
380
  // fountain verbs. Same read-lock lifecycle as withSnapshot, plus parses
377
- // {path, position} and bounds-checks position against the file length so an
378
- // out-of-range offset reaches `ast.GetNodeAtPosition` only as a clean
379
- // error response instead of an internal panic.
381
+ // {path, position} and bounds-checks position against the file length. A
382
+ // position must address an existing byte, so the offset at end-of-file is
383
+ // rejected before it can reach AST navigation.
380
384
  func withSnapshotPosition(args []js.Value, fn func(*snapshotEntry, *ast.SourceFile, int) any) any {
381
385
  return withSnapshot(args, func(entry *snapshotEntry, opts js.Value) any {
382
386
  path := stringProp(opts, "path")
@@ -395,13 +399,38 @@ func withSnapshotPosition(args []js.Value, fn func(*snapshotEntry, *ast.SourceFi
395
399
  if file == nil {
396
400
  return errorResponse(2, fmt.Sprintf("host: file %q not found in snapshot", path))
397
401
  }
398
- if pos > len(file.Text()) {
399
- return errorResponse(2, fmt.Sprintf("host: \"position\" %d exceeds file length %d", pos, len(file.Text())))
402
+ if pos >= len(file.Text()) {
403
+ return errorResponse(2, fmt.Sprintf("host: \"position\" %d is outside file length %d", pos, len(file.Text())))
400
404
  }
401
405
  return fn(entry, file, pos)
402
406
  })
403
407
  }
404
408
 
409
+ // tokenAtPosition resolves the exact token at pos. GetTouchingToken returns a
410
+ // containing AST node when a gap has no token, so preserve the fountain
411
+ // contract by filtering that fallback out explicitly.
412
+ func tokenAtPosition(file *ast.SourceFile, pos int) *ast.Node {
413
+ node := astnav.GetTouchingToken(file, pos)
414
+ if node == nil || !ast.IsTokenKind(node.Kind) {
415
+ return nil
416
+ }
417
+ return node
418
+ }
419
+
420
+ // hasTypeAtLocation mirrors the public checker entrypoint's semantic cases.
421
+ // It deliberately excludes punctuation tokens, whose checker fallback is the
422
+ // error type even though a cursor query has no meaningful type answer.
423
+ func hasTypeAtLocation(node *ast.Node) bool {
424
+ return node != nil && (ast.IsPartOfTypeNode(node) ||
425
+ ast.IsExpressionNode(node) ||
426
+ ast.IsTypeDeclaration(node) ||
427
+ ast.IsTypeDeclarationName(node) ||
428
+ ast.IsBindingElement(node) ||
429
+ ast.IsDeclaration(node) ||
430
+ ast.IsDeclarationNameOrImportPropertyName(node) ||
431
+ ast.IsBindingPattern(node))
432
+ }
433
+
405
434
  // resolveSnapshotFile finds a SourceFile by path, accepting either an
406
435
  // absolute path or a path relative to the snapshot's cwd.
407
436
  func resolveSnapshotFile(entry *snapshotEntry, path string) *ast.SourceFile {
@@ -7,5 +7,11 @@ import type { IMemFSHost } from "./structures/IMemFSHost";
7
7
  * `globalThis.fs` before loading `wasm_exec.js`) and convenience helpers
8
8
  * (`writeFile`, `readFile`, `mkdirp`, …) for seeding source files and reading
9
9
  * compiler output without touching the real filesystem.
10
+ *
11
+ * Every successful mutation leaves a valid tree: `/` stays a directory, each
12
+ * proper ancestor of a node exists and is a directory, and a file has no
13
+ * descendants. An operation that cannot satisfy that throws (`writeFile`,
14
+ * `mkdirp`) or reports a POSIX error through its callback, having changed no
15
+ * node, byte, or descriptor.
10
16
  */
11
17
  export declare function createMemFS(): IMemFSHost;
@@ -55,6 +55,12 @@ function normalize(p) {
55
55
  * `globalThis.fs` before loading `wasm_exec.js`) and convenience helpers
56
56
  * (`writeFile`, `readFile`, `mkdirp`, …) for seeding source files and reading
57
57
  * compiler output without touching the real filesystem.
58
+ *
59
+ * Every successful mutation leaves a valid tree: `/` stays a directory, each
60
+ * proper ancestor of a node exists and is a directory, and a file has no
61
+ * descendants. An operation that cannot satisfy that throws (`writeFile`,
62
+ * `mkdirp`) or reports a POSIX error through its callback, having changed no
63
+ * node, byte, or descriptor.
58
64
  */
59
65
  function createMemFS() {
60
66
  const nodes = new Map();
@@ -64,8 +70,22 @@ function createMemFS() {
64
70
  const fdTable = new Map();
65
71
  let nextFd = 100;
66
72
  // Reserve 1/2 for stdout/stderr writeSync routing.
67
- fdTable.set(1, { path: "/dev/stdout", position: 0, isStdout: true });
68
- fdTable.set(2, { path: "/dev/stderr", position: 0, isStderr: true });
73
+ fdTable.set(1, {
74
+ path: "/dev/stdout",
75
+ position: 0,
76
+ readable: false,
77
+ writable: true,
78
+ append: true,
79
+ isStdout: true,
80
+ });
81
+ fdTable.set(2, {
82
+ path: "/dev/stderr",
83
+ position: 0,
84
+ readable: false,
85
+ writable: true,
86
+ append: true,
87
+ isStderr: true,
88
+ });
69
89
  const pipes = new Map();
70
90
  /**
71
91
  * Drain buffered pipe data into `buffer[offset..offset+length]`. Returns
@@ -99,21 +119,60 @@ function createMemFS() {
99
119
  reader.callback(null, n);
100
120
  }
101
121
  }
102
- /** Silently create any missing ancestor directories for path `p`. */
103
- function ensureParentDirs(p) {
104
- const segments = normalize(p).split("/").filter(Boolean);
105
- segments.pop();
122
+ /** Root-to-leaf path of every segment of `norm` (`/a/b` `["/a", "/a/b"]`). */
123
+ function pathChain(norm) {
124
+ const chain = [];
106
125
  let cursor = "";
107
- for (const seg of segments) {
126
+ for (const seg of norm.split("/").filter(Boolean)) {
108
127
  cursor += "/" + seg;
109
- if (!nodes.has(cursor)) {
110
- nodes.set(cursor, {
111
- kind: "dir",
112
- data: new Uint8Array(),
113
- mtimeMs: Date.now(),
114
- });
115
- }
128
+ chain.push(cursor);
116
129
  }
130
+ return chain;
131
+ }
132
+ /**
133
+ * Validate that every path in root-to-leaf `chain` is or can become a
134
+ * directory, and report the ones that do not exist yet.
135
+ *
136
+ * A segment that exists as a file makes everything below it impossible: a
137
+ * file has no descendants, so creating one there would leave a node map that
138
+ * is not a tree. Validation is deliberately separate from creation so every
139
+ * caller can reject before touching anything, and so a rejected operation
140
+ * never leaves half a directory chain behind.
141
+ */
142
+ function missingDirs(chain, syscall) {
143
+ const missing = [];
144
+ for (const path of chain) {
145
+ const existing = nodes.get(path);
146
+ if (!existing)
147
+ missing.push(path);
148
+ else if (existing.kind !== "dir")
149
+ throw new MemFSError_1.MemFSError("ENOTDIR", syscall, path);
150
+ }
151
+ return missing;
152
+ }
153
+ /** Materialize the directories `missingDirs` reported. */
154
+ function createDirs(paths) {
155
+ for (const path of paths)
156
+ nodes.set(path, {
157
+ kind: "dir",
158
+ data: new Uint8Array(),
159
+ mtimeMs: Date.now(),
160
+ });
161
+ }
162
+ /**
163
+ * Validate that normalized path `norm` may hold a regular file, returning the
164
+ * node already there when one exists.
165
+ *
166
+ * A directory is never silently replaced by a file — including the root,
167
+ * which is a directory node like any other. POSIX answers `EISDIR`, and
168
+ * overwriting the node here would strand every descendant in the map behind a
169
+ * path `readdir` can no longer walk.
170
+ */
171
+ function assertFileTarget(norm, syscall) {
172
+ const existing = nodes.get(norm);
173
+ if (existing && existing.kind !== "file")
174
+ throw new MemFSError_1.MemFSError("EISDIR", syscall, norm);
175
+ return existing;
117
176
  }
118
177
  /** Absolute parent directory of a normalized path (`/` for a top-level path). */
119
178
  function parentDir(norm) {
@@ -138,6 +197,42 @@ function createMemFS() {
138
197
  next.set(data.subarray(0, Math.min(length, data.byteLength)));
139
198
  return next;
140
199
  }
200
+ /**
201
+ * Write `view` through descriptor `entry` and return the bytes stored.
202
+ *
203
+ * Three offsets are possible and only one of them is right per call: an
204
+ * `O_APPEND` descriptor always writes at end-of-file, an explicit `position`
205
+ * writes exactly there without disturbing the cursor (POSIX `pwrite`), and
206
+ * `position: null` writes at the cursor and advances it. A write that starts
207
+ * past end-of-file zero-fills the gap rather than silently relocating.
208
+ *
209
+ * A zero-byte write changes nothing at all, so a cursor sitting past
210
+ * end-of-file cannot extend the file by writing nothing into it.
211
+ */
212
+ function writeThroughDescriptor(entry, view, position, syscall) {
213
+ if (!entry.writable)
214
+ throw new MemFSError_1.MemFSError("EBADF", syscall, entry.path);
215
+ const node = entry.node;
216
+ if (!node)
217
+ throw new MemFSError_1.MemFSError("ENOENT", syscall, entry.path);
218
+ if (node.kind !== "file")
219
+ throw new MemFSError_1.MemFSError("EISDIR", syscall, entry.path);
220
+ const start = entry.append
221
+ ? node.data.byteLength
222
+ : (position ?? entry.position);
223
+ if (!Number.isInteger(start) || start < 0)
224
+ throw new MemFSError_1.MemFSError("EINVAL", syscall, entry.path);
225
+ if (view.byteLength === 0)
226
+ return 0;
227
+ const end = start + view.byteLength;
228
+ if (end > node.data.byteLength)
229
+ node.data = resizeFileData(node.data, end);
230
+ node.data.set(view, start);
231
+ node.mtimeMs = Date.now();
232
+ if (position === null || entry.append)
233
+ entry.position = end;
234
+ return view.byteLength;
235
+ }
141
236
  /**
142
237
  * Move the entire subtree rooted at `src` to `dest`, overwriting any existing
143
238
  * `dest` node. Every descendant key is re-parented so no old-prefix node is
@@ -170,28 +265,26 @@ function createMemFS() {
170
265
  }
171
266
  }
172
267
  function mkdirp(p) {
173
- const segments = normalize(p).split("/").filter(Boolean);
174
- let cursor = "";
175
- for (const seg of segments) {
176
- cursor += "/" + seg;
177
- const existing = nodes.get(cursor);
178
- if (!existing) {
179
- nodes.set(cursor, {
180
- kind: "dir",
181
- data: new Uint8Array(),
182
- mtimeMs: Date.now(),
183
- });
184
- }
185
- else if (existing.kind !== "dir") {
186
- throw new MemFSError_1.MemFSError("ENOTDIR", "mkdir", cursor);
187
- }
188
- }
268
+ // Validate the whole chain before creating any of it: a rejected mkdirp
269
+ // must not leave the prefix it had already walked past behind.
270
+ createDirs(missingDirs(pathChain(normalize(p)), "mkdir"));
189
271
  }
190
272
  function writeFile(p, data) {
191
273
  const norm = normalize(p);
192
- ensureParentDirs(norm);
274
+ // Resolve left to right the way POSIX does: an impossible ancestor is
275
+ // reported before the target, and both are checked before any mutation.
276
+ const missing = missingDirs(pathChain(norm).slice(0, -1), "open");
277
+ const existing = assertFileTarget(norm, "open");
193
278
  const bytes = typeof data === "string" ? encoder.encode(data) : new Uint8Array(data);
194
- nodes.set(norm, { kind: "file", data: bytes, mtimeMs: Date.now() });
279
+ createDirs(missing);
280
+ // Overwriting keeps the same node so descriptors already pointing at this
281
+ // file observe the replacement instead of a detached predecessor.
282
+ if (existing) {
283
+ existing.data = bytes;
284
+ existing.mtimeMs = Date.now();
285
+ }
286
+ else
287
+ nodes.set(norm, { kind: "file", data: bytes, mtimeMs: Date.now() });
195
288
  }
196
289
  function readFile(p) {
197
290
  const node = nodes.get(normalize(p));
@@ -286,36 +379,20 @@ function createMemFS() {
286
379
  console.error("[wasm]", line);
287
380
  return buf.length;
288
381
  }
289
- // Open-file fds (>= 100): append to the underlying file. Browser-hosted
290
- // tools use this path for ordinary virtual files and explicit outputs.
382
+ // Open-file fds (>= 100): write at the descriptor cursor, the way Node's
383
+ // own `writeSync` without a position does. Browser-hosted tools use this
384
+ // path for ordinary virtual files and explicit outputs.
385
+ //
386
+ // An unknown or already-closed fd throws instead of being diverted into
387
+ // the captured stderr: reporting the byte count of a write nobody
388
+ // performed made the caller continue on a false success and quietly
389
+ // contaminated a diagnostic channel consumers read.
291
390
  const entry = fdTable.get(fd);
292
- if (entry) {
293
- const node = nodes.get(entry.path);
294
- if (node && node.kind === "file") {
295
- // subarray(0) is a zero-copy view over the full incoming buffer; the
296
- // bytes are copied into `next` before writeSync returns.
297
- const incoming = buf.subarray(0);
298
- const existing = node.data;
299
- const next = new Uint8Array(existing.byteLength + incoming.byteLength);
300
- next.set(existing, 0);
301
- next.set(incoming, existing.byteLength);
302
- node.data = next;
303
- node.mtimeMs = Date.now();
304
- entry.position = next.byteLength;
305
- return incoming.byteLength;
306
- }
307
- }
308
- // Unknown fd. Fall back to the stderr buffer so the bytes aren't lost
309
- // entirely (and surface as a console.error so the regression is
310
- // visible to whoever's looking).
311
- stderr.buffer += decoder.decode(buf);
312
- // eslint-disable-next-line no-console
313
- console.error("[wasm] writeSync to unknown fd " +
314
- fd +
315
- " (" +
316
- buf.byteLength +
317
- " bytes); routed to stderr buffer");
318
- return buf.length;
391
+ if (!entry)
392
+ throw new MemFSError_1.MemFSError("EBADF", "write");
393
+ // subarray(0) is a zero-copy view over the full incoming buffer; the
394
+ // bytes are copied into the node before writeSync returns.
395
+ return writeThroughDescriptor(entry, buf.subarray(0), null, "write");
319
396
  },
320
397
  write(fd, buf, offset, length, position, callback) {
321
398
  try {
@@ -337,44 +414,88 @@ function createMemFS() {
337
414
  callback(null, length);
338
415
  return;
339
416
  }
340
- if (position !== null && position !== 0) {
341
- callback(new MemFSError_1.MemFSError("ESPIPE", "write"), 0);
417
+ const view = buf.subarray(offset, offset + length);
418
+ // The stdout/stderr capture buffers are streams, not files: they have
419
+ // no seekable offset, so an explicit position is meaningless on them.
420
+ if (fd === 1 || fd === 2) {
421
+ if (position !== null && position !== 0)
422
+ throw new MemFSError_1.MemFSError("ESPIPE", "write");
423
+ callback(null, this.writeSync(fd, view));
342
424
  return;
343
425
  }
344
- const view = buf.subarray(offset, offset + length);
345
- const written = this.writeSync(fd, view);
346
- callback(null, written);
426
+ const entry = fdTable.get(fd);
427
+ if (!entry)
428
+ throw new MemFSError_1.MemFSError("EBADF", "write");
429
+ callback(null, writeThroughDescriptor(entry, view, position, "write"));
347
430
  }
348
431
  catch (err) {
349
432
  callback(err, 0);
350
433
  }
351
434
  },
435
+ // Every rejection happens before the first mutation and before a
436
+ // descriptor is minted, so a refused open leaves neither a half-created
437
+ // node nor a leaked fd behind.
352
438
  open(p, flags, _mode, callback) {
353
- const norm = normalize(p);
354
- const node = nodes.get(norm);
355
- const creating = (flags & (this.constants.O_CREAT ?? 0)) !== 0;
356
- if (!node) {
357
- if (!creating) {
358
- callback(new MemFSError_1.MemFSError("ENOENT", "open", norm), -1);
359
- return;
439
+ try {
440
+ const norm = normalize(p);
441
+ const creating = (flags & (this.constants.O_CREAT ?? 0)) !== 0;
442
+ const exclusive = (flags & (this.constants.O_EXCL ?? 0)) !== 0;
443
+ const truncating = (flags & (this.constants.O_TRUNC ?? 0)) !== 0;
444
+ const appending = (flags & (this.constants.O_APPEND ?? 0)) !== 0;
445
+ const directoryOnly = (flags & (this.constants.O_DIRECTORY ?? 0)) !== 0;
446
+ // The access mode is the low bits of `flags`: absent both means
447
+ // read-only, and `O_RDWR` alongside `O_WRONLY` still grants both.
448
+ const writeOnly = (flags & (this.constants.O_WRONLY ?? 0)) !== 0;
449
+ const readWrite = (flags & (this.constants.O_RDWR ?? 0)) !== 0;
450
+ const writable = writeOnly || readWrite;
451
+ const readable = !writeOnly || readWrite;
452
+ let node = nodes.get(norm);
453
+ if (!node) {
454
+ if (!creating)
455
+ throw new MemFSError_1.MemFSError("ENOENT", "open", norm);
456
+ // `open` can only ever create a regular file, so a caller demanding a
457
+ // directory cannot be satisfied by creating one.
458
+ if (directoryOnly)
459
+ throw new MemFSError_1.MemFSError("ENOTDIR", "open", norm);
460
+ // Creating the missing ancestor chain is the documented job of
461
+ // `writeFile` and `mkdirp`; the low-level `open` never promised it.
462
+ const missing = missingDirs(pathChain(norm).slice(0, -1), "open");
463
+ if (missing.length > 0)
464
+ throw new MemFSError_1.MemFSError("ENOENT", "open", missing[0]);
465
+ node = { kind: "file", data: new Uint8Array(), mtimeMs: Date.now() };
466
+ nodes.set(norm, node);
360
467
  }
361
- ensureParentDirs(norm);
362
- nodes.set(norm, {
363
- kind: "file",
364
- data: new Uint8Array(),
365
- mtimeMs: Date.now(),
468
+ else {
469
+ if (creating && exclusive)
470
+ throw new MemFSError_1.MemFSError("EEXIST", "open", norm);
471
+ if (node.kind === "dir") {
472
+ // A directory opens read-only — Go stats the fd and lists the path
473
+ // that way. Writing to it or truncating it would turn it into a
474
+ // file and orphan every descendant.
475
+ if (writable || truncating)
476
+ throw new MemFSError_1.MemFSError("EISDIR", "open", norm);
477
+ }
478
+ else if (directoryOnly)
479
+ throw new MemFSError_1.MemFSError("ENOTDIR", "open", norm);
480
+ }
481
+ if (truncating) {
482
+ node.data = new Uint8Array();
483
+ node.mtimeMs = Date.now();
484
+ }
485
+ const fd = nextFd++;
486
+ fdTable.set(fd, {
487
+ path: norm,
488
+ node,
489
+ position: 0,
490
+ readable,
491
+ writable,
492
+ append: appending,
366
493
  });
494
+ callback(null, fd);
367
495
  }
368
- const fd = nextFd++;
369
- fdTable.set(fd, { path: norm, position: 0 });
370
- if ((flags & (this.constants.O_TRUNC ?? 0)) !== 0) {
371
- nodes.set(norm, {
372
- kind: "file",
373
- data: new Uint8Array(),
374
- mtimeMs: Date.now(),
375
- });
496
+ catch (err) {
497
+ callback(err, -1);
376
498
  }
377
- callback(null, fd);
378
499
  },
379
500
  close(fd, callback) {
380
501
  const pipeState = pipes.get(fd);
@@ -421,17 +542,30 @@ function createMemFS() {
421
542
  callback(new MemFSError_1.MemFSError("EBADF", "read"), 0);
422
543
  return;
423
544
  }
424
- const node = nodes.get(entry.path);
545
+ // A write-only descriptor is not a readable one. POSIX answers EBADF for
546
+ // an operation the descriptor's access mode never granted.
547
+ if (!entry.readable) {
548
+ callback(new MemFSError_1.MemFSError("EBADF", "read", entry.path), 0);
549
+ return;
550
+ }
551
+ const node = entry.node;
425
552
  if (!node || node.kind !== "file") {
426
553
  callback(new MemFSError_1.MemFSError("ENOENT", "read", entry.path), 0);
427
554
  return;
428
555
  }
429
556
  const start = position ?? entry.position;
557
+ if (!Number.isInteger(start) || start < 0) {
558
+ callback(new MemFSError_1.MemFSError("EINVAL", "read", entry.path), 0);
559
+ return;
560
+ }
430
561
  const end = Math.min(start + length, node.data.byteLength);
431
562
  const slice = node.data.subarray(start, end);
432
563
  buffer.set(slice, offset);
564
+ // The cursor advances by the bytes actually read. Assigning `end` would
565
+ // rewind a cursor already past end-of-file (after `ftruncate`, say) back
566
+ // onto live bytes and make the next sequential write overwrite them.
433
567
  if (position === null)
434
- entry.position = end;
568
+ entry.position = start + slice.byteLength;
435
569
  callback(null, slice.byteLength);
436
570
  },
437
571
  readdir(p, callback) {
@@ -444,7 +578,19 @@ function createMemFS() {
444
578
  },
445
579
  mkdir(p, _perm, callback) {
446
580
  try {
447
- mkdirp(p);
581
+ const norm = normalize(p);
582
+ if (nodes.has(norm))
583
+ throw new MemFSError_1.MemFSError("EEXIST", "mkdir", norm);
584
+ const parent = nodes.get(parentDir(norm));
585
+ if (!parent)
586
+ throw new MemFSError_1.MemFSError("ENOENT", "mkdir", parentDir(norm));
587
+ if (parent.kind !== "dir")
588
+ throw new MemFSError_1.MemFSError("ENOTDIR", "mkdir", parentDir(norm));
589
+ nodes.set(norm, {
590
+ kind: "dir",
591
+ data: new Uint8Array(),
592
+ mtimeMs: Date.now(),
593
+ });
448
594
  callback(null);
449
595
  }
450
596
  catch (err) {
@@ -463,9 +609,9 @@ function createMemFS() {
463
609
  this.stat(p, callback);
464
610
  },
465
611
  fstat(fd, callback) {
466
- // fstat against a pipe end returns a synthetic file-stat. Go's
467
- // os.Pipe-backed File uses fstat at construction time to populate
468
- // Stat_t; without this it errors and falls back to invalid fds.
612
+ // fstat against a pipe end returns a synthetic file-stat so a direct
613
+ // JavaScript caller that wraps the fd in a file-like abstraction can
614
+ // populate its stat fields. A pipe end has no node in the tree.
469
615
  if (pipes.has(fd)) {
470
616
  callback(null, makeStats({
471
617
  kind: "file",
@@ -480,7 +626,9 @@ function createMemFS() {
480
626
  return;
481
627
  }
482
628
  try {
483
- callback(null, statSync(entry.path));
629
+ if (!entry.node)
630
+ throw new MemFSError_1.MemFSError("ENOENT", "fstat", entry.path);
631
+ callback(null, makeStats(entry.node));
484
632
  }
485
633
  catch (err) {
486
634
  callback(err, undefined);
@@ -644,7 +792,11 @@ function createMemFS() {
644
792
  callback(new MemFSError_1.MemFSError("EINVAL", "ftruncate"));
645
793
  return;
646
794
  }
647
- const node = nodes.get(entry.path);
795
+ if (!entry.writable) {
796
+ callback(new MemFSError_1.MemFSError("EBADF", "ftruncate", entry.path));
797
+ return;
798
+ }
799
+ const node = entry.node;
648
800
  if (!node || node.kind !== "file") {
649
801
  callback(new MemFSError_1.MemFSError("EINVAL", "ftruncate", entry.path));
650
802
  return;