@orkestrel/test 0.0.1 → 0.0.3

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.
package/README.md CHANGED
@@ -1,9 +1,11 @@
1
1
  # @orkestrel/test
2
2
 
3
- The test helpers every `@orkestrel` package had already written for itself, published once. A call
4
- recorder that is a real callback rather than a spy. A real host delay. A throw-to-value converter and
5
- a presence narrower, so `!` and `as` stay banned in tests. Two async collectors and a JSON copier. A
6
- scratch directory the test owns and destroys, and a symlink-refusing source-file walker. Add it as a
3
+ The test helpers the `@orkestrel` fleet kept rewriting, published once. A call recorder that is a
4
+ real callback rather than a spy. A real host delay. A throw-to-value converter and a presence
5
+ narrower, so `!` and `as` stay banned in tests. Two async collectors and a JSON copier. A scratch
6
+ directory the test owns and destroys, and a symlink-refusing source-file walker. A helper ships here
7
+ only when enough packages had already written their own; the guide's
8
+ [Limits](guides/test.md#limits) section states that rule and what it excluded. Add it as a
7
9
  devDependency; nothing here runs in production code. Part of the `@orkestrel` line.
8
10
 
9
11
  It has **zero runtime dependencies**, and no exported signature names an `@orkestrel/*` type. Both
@@ -48,16 +50,20 @@ recorder.clear() // truncates in place, so a `calls` reference captured earlier
48
50
  captureError(() => loader.read('missing.txt')) // the thrown value, or undefined
49
51
  requireValue(scratch.read('input.txt')) // 'hello' — narrows `string | undefined` without `!`
50
52
 
53
+ scratch.remove('input.txt') // one contained entry, subtree and all; a missing target is a no-op
51
54
  scratch.destroy() // idempotent, and it removes only the directory it allocated
52
55
  ```
53
56
 
54
57
  The rest of core is `collect` (drains an async iterable), `collectStream` (drains a
55
- `ReadableStream`), `roundTripJSON` (copies a `JSONValue`, and throws rather than turning a non-finite
56
- number into `null`), and `resolveRoot` (the directory above the calling module, from `import.meta`).
58
+ `ReadableStream`), `roundTripJSON` (copies any value `JSONSafe` accepts, including an
59
+ interface-typed one, and throws rather than turning a non-finite number into `null`), and
60
+ `resolveRoot` (the directory above the calling module, from `import.meta`).
57
61
 
58
62
  The server face adds `readInventory`, which reads a checkout into a map of root-relative path to
59
- file text that a parity suite can assert against, plus `resolveContained`, the lexical check it
60
- refuses escapes with.
63
+ file text that a parity suite can assert against, plus the three pure leaves behind it and
64
+ `createScratch`: `resolveContained`, the lexical check both refuse escapes with; `isExcluded`, the
65
+ exclusion rule the walk applies; and `matchesIdentity`, the comparison `destroy()` makes before it
66
+ removes anything.
61
67
 
62
68
  ```ts
63
69
  import { resolveRoot } from '@orkestrel/test'
@@ -66,7 +72,7 @@ import { readInventory } from '@orkestrel/test/server'
66
72
  // A suite in tests/ is one directory below the workspace root, which is what `resolveRoot` returns.
67
73
  const root = resolveRoot(import.meta)
68
74
 
69
- // Nothing is walked by default, so the directories are a required argument rather than an option.
75
+ // Nothing is walked by default, so the targets are a required argument rather than an option.
70
76
  const sources = readInventory(root, ['src/core', 'src/server'], { extensions: ['.ts'] })
71
77
 
72
78
  Object.keys(sources)
@@ -77,7 +83,12 @@ Object.keys(sources)
77
83
  sources['src/core/index.ts']
78
84
  // "export * from './types.js'\nexport * from './helpers.js'\nexport * from './factories.js'\n"
79
85
 
80
- // An `exclude` entry is a whole key. A file key drops that file.
86
+ // A target is a file or a directory. A named file is read whatever the extension filter says.
87
+ Object.keys(readInventory(root, ['package.json', 'src/core'], { extensions: ['.ts'] }))
88
+ // ['package.json', 'src/core/factories.ts', 'src/core/helpers.ts', 'src/core/index.ts',
89
+ // 'src/core/types.ts']
90
+
91
+ // An `exclude` entry matches whole key segments. A file key drops that file.
81
92
  Object.keys(
82
93
  readInventory(root, ['src/core'], { extensions: ['.ts'], exclude: ['src/core/index.ts'] }),
83
94
  )
@@ -94,17 +105,24 @@ conversion. Keys are inserted in sorted order, and a plain object reads that ord
94
105
  key that is not integer-like.
95
106
 
96
107
  Two boundaries are worth stating up front, because the two filesystem helpers promise different
97
- things. `createScratch` allocates its own directory at POSIX mode `0700` and refuses a path that
98
- lexically escapes it. The suite asserts those bits on POSIX and proves nothing about a host that
99
- emulates them. The mode keeps another uid out, and neither a sibling test worker nor the code under
100
- test is another uid. It does not walk segments for symbolic links. A link inside its own allocation
101
- was created by the test process or by the code the test drives handing that code `scratch.path`
102
- is the ordinary use of this helper and `write` and `read` follow such a link, so either can reach
103
- outside the allocation through it. `readInventory` walks a directory you supply, usually a checkout
104
- the test did not create, so it throws on a symlinked root or requested directory and skips a
105
- symlink met while walking. Neither is a sandbox against hostile filesystem content:
106
- those refusals stop accidental escape, not an adversary who can create hard links where the test
107
- process already writes.
108
+ things. `createScratch` allocates its own directory at POSIX mode `0700` under the host temporary
109
+ directory, or under a `parent` you name and refuses a path that lexically escapes it. The suite
110
+ asserts those bits on POSIX and proves nothing about a host that emulates them. The mode keeps
111
+ another uid out, and neither a sibling test worker nor the code under test is another uid. It does
112
+ not walk segments for symbolic links. A link inside its own allocation was created by the test
113
+ process, by the code the test drives, or by this package's own `link` handing that code
114
+ `scratch.path` is the ordinary use of this helper — and a contained path reaches outside the
115
+ allocation through one. The guide's [traversal](guides/test.md#traversal) section states what each
116
+ member does with a link it meets. Naming a `parent` inside a package tree costs one more thing:
117
+ while the allocation exists, everything that walks that tree sees it. `destroy()` is unaffected by
118
+ where the allocation sits, because it matches the allocation's identity rather than its path. One
119
+ field of that identity is the host's to supply: where a host reports no real creation time, libuv
120
+ reports `ctime` in its place, the first write moves it, and `destroy()` then removes nothing and
121
+ returns as if it had. `readInventory` walks a checkout you supply, usually one the test did not
122
+ create, so it throws on a symlinked root or named target, throws on a named target whose real path
123
+ leaves the root through a link in the middle, and skips a symlink met while walking. Neither is a
124
+ sandbox against hostile filesystem content: those refusals stop accidental escape, not an adversary
125
+ who can create hard links where the test process already writes.
108
126
 
109
127
  ## Guide
110
128
 
@@ -69,14 +69,16 @@ async function collectStream(stream) {
69
69
  /**
70
70
  * Copies a JSON value through serialization and parsing.
71
71
  *
72
- * @typeParam T - The JSON value type.
73
- * @param value - The value to copy.
72
+ * @typeParam T - The copied value's type, which the copy keeps.
73
+ * @param value - The value to copy, bounded by its own `JSONSafe` projection.
74
74
  * @returns The parsed JSON copy.
75
75
  * @remarks Non-finite numbers throw because JSON would replace them with `null`. Negative zero is
76
- * normalized to zero by JSON serialization.
76
+ * normalized to zero by JSON serialization. The bound intersects `JSONSafe<T>` rather than
77
+ * constraining `T` to `JSONValue`, so an interface-typed value round-trips.
77
78
  */
78
79
  function roundTripJSON(value) {
79
80
  const serialized = JSON.stringify(value, (_key, current) => {
81
+ if (current === void 0 || typeof current === "function" || typeof current === "symbol") throw new Error("JSON values must not contain undefined, functions, or symbols");
80
82
  if (typeof current === "number" && !Number.isFinite(current)) throw new Error("JSON values must contain finite numbers");
81
83
  return current;
82
84
  });
@@ -84,7 +86,6 @@ function roundTripJSON(value) {
84
86
  const pending = [parsed];
85
87
  while (pending.length > 0) {
86
88
  const current = pending.pop();
87
- if (current === void 0) continue;
88
89
  if (typeof current === "number" && !Number.isFinite(current)) throw new Error("JSON values must contain finite numbers");
89
90
  if (Array.isArray(current)) for (const child of current) pending.push(child);
90
91
  else if (typeof current === "object" && current !== null) for (const child of Object.values(current)) pending.push(child);
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":[],"sources":["../../../src/core/helpers.ts","../../../src/core/factories.ts"],"sourcesContent":["import type { JSONValue } from './types.js'\n\n/**\n * Waits for a host timer to elapse.\n *\n * @param ms - The delay in milliseconds.\n * @returns A promise that resolves after the timer fires.\n */\nexport function waitForDelay(ms = 0): Promise<void> {\n\treturn new Promise((resolve) => setTimeout(resolve, ms))\n}\n\n/**\n * Captures the value thrown by a thunk.\n *\n * @param thunk - The work whose thrown value to capture.\n * @returns The thrown value, or `undefined` when the thunk completes.\n */\nexport function captureError(thunk: () => unknown): unknown {\n\ttry {\n\t\tthunk()\n\t} catch (error) {\n\t\treturn error\n\t}\n\treturn undefined\n}\n\n/**\n * Requires a value to be present.\n *\n * @typeParam T - The required value type.\n * @param value - The value to check.\n * @param message - The error message used when the value is absent.\n * @returns The present value.\n */\nexport function requireValue<T>(value: T | null | undefined, message = 'Value is required'): T {\n\tif (value === null || value === undefined) throw new Error(message)\n\treturn value\n}\n\n/**\n * Collects every value from an async iterable.\n *\n * @typeParam T - The yielded value type.\n * @param source - The async iterable to drain.\n * @returns The yielded values in iteration order.\n */\nexport async function collect<T>(source: AsyncIterable<T>): Promise<readonly T[]> {\n\tconst values: T[] = []\n\tfor await (const value of source) values.push(value)\n\treturn values\n}\n\n/**\n * Collects every value from a readable stream.\n *\n * @typeParam T - The streamed value type.\n * @param stream - The readable stream to drain.\n * @returns The streamed values in read order.\n */\nexport async function collectStream<T>(stream: ReadableStream<T>): Promise<readonly T[]> {\n\tconst reader = stream.getReader()\n\tconst values: T[] = []\n\ttry {\n\t\twhile (true) {\n\t\t\tconst result = await reader.read()\n\t\t\tif (result.done) return values\n\t\t\tvalues.push(result.value)\n\t\t}\n\t} finally {\n\t\treader.releaseLock()\n\t}\n}\n\n/**\n * Copies a JSON value through serialization and parsing.\n *\n * @typeParam T - The JSON value type.\n * @param value - The value to copy.\n * @returns The parsed JSON copy.\n * @remarks Non-finite numbers throw because JSON would replace them with `null`. Negative zero is\n * normalized to zero by JSON serialization.\n */\nexport function roundTripJSON<T extends JSONValue>(value: T): T {\n\tconst serialized = JSON.stringify(value, (_key, current) => {\n\t\tif (typeof current === 'number' && !Number.isFinite(current)) {\n\t\t\tthrow new Error('JSON values must contain finite numbers')\n\t\t}\n\t\treturn current\n\t})\n\tconst parsed: T = JSON.parse(serialized)\n\tconst pending: JSONValue[] = [parsed]\n\twhile (pending.length > 0) {\n\t\tconst current = pending.pop()\n\t\tif (current === undefined) continue\n\t\tif (typeof current === 'number' && !Number.isFinite(current)) {\n\t\t\tthrow new Error('JSON values must contain finite numbers')\n\t\t}\n\t\tif (Array.isArray(current)) {\n\t\t\tfor (const child of current) pending.push(child)\n\t\t} else if (typeof current === 'object' && current !== null) {\n\t\t\tfor (const child of Object.values(current)) pending.push(child)\n\t\t}\n\t}\n\treturn parsed\n}\n\n/**\n * Resolves the parent directory of a calling module, which is the workspace root when called from\n * the conventional `tests/setup.ts` location.\n *\n * @param meta - The calling module metadata.\n * @returns The root URL one directory above the calling file.\n */\nexport function resolveRoot(meta: ImportMeta): URL {\n\treturn new URL('../', meta.url)\n}\n","import type { RecorderInterface } from './types.js'\n\n/**\n * Creates a recorder for callback arguments.\n *\n * @typeParam TArgs - The argument tuple to record.\n * @returns A recorder whose handler appends calls in order.\n */\nexport function createRecorder<TArgs extends readonly unknown[]>(): RecorderInterface<TArgs> {\n\tconst calls: TArgs[] = []\n\treturn {\n\t\tcalls,\n\t\tget count() {\n\t\t\treturn calls.length\n\t\t},\n\t\thandler(...args) {\n\t\t\tcalls.push(args)\n\t\t},\n\t\tclear() {\n\t\t\tcalls.length = 0\n\t\t},\n\t}\n}\n"],"mappings":";;;;;;;;AAQA,SAAgB,aAAa,KAAK,GAAkB;CACnD,OAAO,IAAI,SAAS,YAAY,WAAW,SAAS,EAAE,CAAC;AACxD;;;;;;;AAQA,SAAgB,aAAa,OAA+B;CAC3D,IAAI;EACH,MAAM;CACP,SAAS,OAAO;EACf,OAAO;CACR;AAED;;;;;;;;;AAUA,SAAgB,aAAgB,OAA6B,UAAU,qBAAwB;CAC9F,IAAI,UAAU,QAAQ,UAAU,KAAA,GAAW,MAAM,IAAI,MAAM,OAAO;CAClE,OAAO;AACR;;;;;;;;AASA,eAAsB,QAAW,QAAiD;CACjF,MAAM,SAAc,CAAC;CACrB,WAAW,MAAM,SAAS,QAAQ,OAAO,KAAK,KAAK;CACnD,OAAO;AACR;;;;;;;;AASA,eAAsB,cAAiB,QAAkD;CACxF,MAAM,SAAS,OAAO,UAAU;CAChC,MAAM,SAAc,CAAC;CACrB,IAAI;EACH,OAAO,MAAM;GACZ,MAAM,SAAS,MAAM,OAAO,KAAK;GACjC,IAAI,OAAO,MAAM,OAAO;GACxB,OAAO,KAAK,OAAO,KAAK;EACzB;CACD,UAAU;EACT,OAAO,YAAY;CACpB;AACD;;;;;;;;;;AAWA,SAAgB,cAAmC,OAAa;CAC/D,MAAM,aAAa,KAAK,UAAU,QAAQ,MAAM,YAAY;EAC3D,IAAI,OAAO,YAAY,YAAY,CAAC,OAAO,SAAS,OAAO,GAC1D,MAAM,IAAI,MAAM,yCAAyC;EAE1D,OAAO;CACR,CAAC;CACD,MAAM,SAAY,KAAK,MAAM,UAAU;CACvC,MAAM,UAAuB,CAAC,MAAM;CACpC,OAAO,QAAQ,SAAS,GAAG;EAC1B,MAAM,UAAU,QAAQ,IAAI;EAC5B,IAAI,YAAY,KAAA,GAAW;EAC3B,IAAI,OAAO,YAAY,YAAY,CAAC,OAAO,SAAS,OAAO,GAC1D,MAAM,IAAI,MAAM,yCAAyC;EAE1D,IAAI,MAAM,QAAQ,OAAO,GACxB,KAAK,MAAM,SAAS,SAAS,QAAQ,KAAK,KAAK;OACzC,IAAI,OAAO,YAAY,YAAY,YAAY,MACrD,KAAK,MAAM,SAAS,OAAO,OAAO,OAAO,GAAG,QAAQ,KAAK,KAAK;CAEhE;CACA,OAAO;AACR;;;;;;;;AASA,SAAgB,YAAY,MAAuB;CAClD,OAAO,IAAI,IAAI,OAAO,KAAK,GAAG;AAC/B;;;;;;;;;AC5GA,SAAgB,iBAA6E;CAC5F,MAAM,QAAiB,CAAC;CACxB,OAAO;EACN;EACA,IAAI,QAAQ;GACX,OAAO,MAAM;EACd;EACA,QAAQ,GAAG,MAAM;GAChB,MAAM,KAAK,IAAI;EAChB;EACA,QAAQ;GACP,MAAM,SAAS;EAChB;CACD;AACD"}
1
+ {"version":3,"file":"index.cjs","names":[],"sources":["../../../src/core/helpers.ts","../../../src/core/factories.ts"],"sourcesContent":["import type { JSONSafe } from './types.js'\n\n/**\n * Waits for a host timer to elapse.\n *\n * @param ms - The delay in milliseconds.\n * @returns A promise that resolves after the timer fires.\n */\nexport function waitForDelay(ms = 0): Promise<void> {\n\treturn new Promise((resolve) => setTimeout(resolve, ms))\n}\n\n/**\n * Captures the value thrown by a thunk.\n *\n * @param thunk - The work whose thrown value to capture.\n * @returns The thrown value, or `undefined` when the thunk completes.\n */\nexport function captureError(thunk: () => unknown): unknown {\n\ttry {\n\t\tthunk()\n\t} catch (error) {\n\t\treturn error\n\t}\n\treturn undefined\n}\n\n/**\n * Requires a value to be present.\n *\n * @typeParam T - The required value type.\n * @param value - The value to check.\n * @param message - The error message used when the value is absent.\n * @returns The present value.\n */\nexport function requireValue<T>(value: T | null | undefined, message = 'Value is required'): T {\n\tif (value === null || value === undefined) throw new Error(message)\n\treturn value\n}\n\n/**\n * Collects every value from an async iterable.\n *\n * @typeParam T - The yielded value type.\n * @param source - The async iterable to drain.\n * @returns The yielded values in iteration order.\n */\nexport async function collect<T>(source: AsyncIterable<T>): Promise<readonly T[]> {\n\tconst values: T[] = []\n\tfor await (const value of source) values.push(value)\n\treturn values\n}\n\n/**\n * Collects every value from a readable stream.\n *\n * @typeParam T - The streamed value type.\n * @param stream - The readable stream to drain.\n * @returns The streamed values in read order.\n */\nexport async function collectStream<T>(stream: ReadableStream<T>): Promise<readonly T[]> {\n\tconst reader = stream.getReader()\n\tconst values: T[] = []\n\ttry {\n\t\twhile (true) {\n\t\t\tconst result = await reader.read()\n\t\t\tif (result.done) return values\n\t\t\tvalues.push(result.value)\n\t\t}\n\t} finally {\n\t\treader.releaseLock()\n\t}\n}\n\n/**\n * Copies a JSON value through serialization and parsing.\n *\n * @typeParam T - The copied value's type, which the copy keeps.\n * @param value - The value to copy, bounded by its own `JSONSafe` projection.\n * @returns The parsed JSON copy.\n * @remarks Non-finite numbers throw because JSON would replace them with `null`. Negative zero is\n * normalized to zero by JSON serialization. The bound intersects `JSONSafe<T>` rather than\n * constraining `T` to `JSONValue`, so an interface-typed value round-trips.\n */\nexport function roundTripJSON<T>(value: T & JSONSafe<T>): T {\n\tconst serialized = JSON.stringify(value, (_key, current) => {\n\t\tif (current === undefined || typeof current === 'function' || typeof current === 'symbol') {\n\t\t\tthrow new Error('JSON values must not contain undefined, functions, or symbols')\n\t\t}\n\t\tif (typeof current === 'number' && !Number.isFinite(current)) {\n\t\t\tthrow new Error('JSON values must contain finite numbers')\n\t\t}\n\t\treturn current\n\t})\n\tconst parsed: T = JSON.parse(serialized)\n\tconst pending: unknown[] = [parsed]\n\twhile (pending.length > 0) {\n\t\tconst current = pending.pop()\n\t\tif (typeof current === 'number' && !Number.isFinite(current)) {\n\t\t\tthrow new Error('JSON values must contain finite numbers')\n\t\t}\n\t\tif (Array.isArray(current)) {\n\t\t\tfor (const child of current) pending.push(child)\n\t\t} else if (typeof current === 'object' && current !== null) {\n\t\t\tfor (const child of Object.values(current)) pending.push(child)\n\t\t}\n\t}\n\treturn parsed\n}\n\n/**\n * Resolves the parent directory of a calling module, which is the workspace root when called from\n * the conventional `tests/setup.ts` location.\n *\n * @param meta - The calling module metadata.\n * @returns The root URL one directory above the calling file.\n */\nexport function resolveRoot(meta: ImportMeta): URL {\n\treturn new URL('../', meta.url)\n}\n","import type { RecorderInterface } from './types.js'\n\n/**\n * Creates a recorder for callback arguments.\n *\n * @typeParam TArgs - The argument tuple to record.\n * @returns A recorder whose handler appends calls in order.\n */\nexport function createRecorder<TArgs extends readonly unknown[]>(): RecorderInterface<TArgs> {\n\tconst calls: TArgs[] = []\n\treturn {\n\t\tcalls,\n\t\tget count() {\n\t\t\treturn calls.length\n\t\t},\n\t\thandler(...args) {\n\t\t\tcalls.push(args)\n\t\t},\n\t\tclear() {\n\t\t\tcalls.length = 0\n\t\t},\n\t}\n}\n"],"mappings":";;;;;;;;AAQA,SAAgB,aAAa,KAAK,GAAkB;CACnD,OAAO,IAAI,SAAS,YAAY,WAAW,SAAS,EAAE,CAAC;AACxD;;;;;;;AAQA,SAAgB,aAAa,OAA+B;CAC3D,IAAI;EACH,MAAM;CACP,SAAS,OAAO;EACf,OAAO;CACR;AAED;;;;;;;;;AAUA,SAAgB,aAAgB,OAA6B,UAAU,qBAAwB;CAC9F,IAAI,UAAU,QAAQ,UAAU,KAAA,GAAW,MAAM,IAAI,MAAM,OAAO;CAClE,OAAO;AACR;;;;;;;;AASA,eAAsB,QAAW,QAAiD;CACjF,MAAM,SAAc,CAAC;CACrB,WAAW,MAAM,SAAS,QAAQ,OAAO,KAAK,KAAK;CACnD,OAAO;AACR;;;;;;;;AASA,eAAsB,cAAiB,QAAkD;CACxF,MAAM,SAAS,OAAO,UAAU;CAChC,MAAM,SAAc,CAAC;CACrB,IAAI;EACH,OAAO,MAAM;GACZ,MAAM,SAAS,MAAM,OAAO,KAAK;GACjC,IAAI,OAAO,MAAM,OAAO;GACxB,OAAO,KAAK,OAAO,KAAK;EACzB;CACD,UAAU;EACT,OAAO,YAAY;CACpB;AACD;;;;;;;;;;;AAYA,SAAgB,cAAiB,OAA2B;CAC3D,MAAM,aAAa,KAAK,UAAU,QAAQ,MAAM,YAAY;EAC3D,IAAI,YAAY,KAAA,KAAa,OAAO,YAAY,cAAc,OAAO,YAAY,UAChF,MAAM,IAAI,MAAM,+DAA+D;EAEhF,IAAI,OAAO,YAAY,YAAY,CAAC,OAAO,SAAS,OAAO,GAC1D,MAAM,IAAI,MAAM,yCAAyC;EAE1D,OAAO;CACR,CAAC;CACD,MAAM,SAAY,KAAK,MAAM,UAAU;CACvC,MAAM,UAAqB,CAAC,MAAM;CAClC,OAAO,QAAQ,SAAS,GAAG;EAC1B,MAAM,UAAU,QAAQ,IAAI;EAC5B,IAAI,OAAO,YAAY,YAAY,CAAC,OAAO,SAAS,OAAO,GAC1D,MAAM,IAAI,MAAM,yCAAyC;EAE1D,IAAI,MAAM,QAAQ,OAAO,GACxB,KAAK,MAAM,SAAS,SAAS,QAAQ,KAAK,KAAK;OACzC,IAAI,OAAO,YAAY,YAAY,YAAY,MACrD,KAAK,MAAM,SAAS,OAAO,OAAO,OAAO,GAAG,QAAQ,KAAK,KAAK;CAEhE;CACA,OAAO;AACR;;;;;;;;AASA,SAAgB,YAAY,MAAuB;CAClD,OAAO,IAAI,IAAI,OAAO,KAAK,GAAG;AAC/B;;;;;;;;;AC/GA,SAAgB,iBAA6E;CAC5F,MAAM,QAAiB,CAAC;CACxB,OAAO;EACN;EACA,IAAI,QAAQ;GACX,OAAO,MAAM;EACd;EACA,QAAQ,GAAG,MAAM;GAChB,MAAM,KAAK,IAAI;EAChB;EACA,QAAQ;GACP,MAAM,SAAS;EAChB;CACD;AACD"}
@@ -32,6 +32,39 @@ export declare function collectStream<T>(stream: ReadableStream<T>): Promise<rea
32
32
  */
33
33
  export declare function createRecorder<TArgs extends readonly unknown[]>(): RecorderInterface<TArgs>;
34
34
 
35
+ /**
36
+ * The JSON-safe projection of a type: every member JSON preserves, mapped to itself, and every
37
+ * member it does not, mapped to `never`.
38
+ *
39
+ * @typeParam T - The type to project.
40
+ * @remarks Intersect a parameter with this rather than constraining it to `JSONValue`. A `JSONValue`
41
+ * constraint rejects every `interface`, because TypeScript grants an implicit index signature to a
42
+ * type alias and never to an interface, and interfaces are what this project's public types are. A
43
+ * value whose type survives the projection satisfies the intersection unchanged; one that carries a
44
+ * method, a `Date`, a `Map`, the opaque `object` type, or a symbol-keyed member meets `never` at that
45
+ * member and is rejected there. A member typed `undefined` is rejected the same way, because
46
+ * serialization drops it from an object and rewrites it to `null` in an array, so the returned type
47
+ * would claim a member the copy does not carry. An optional member survives, since its declared type
48
+ * still narrows to what JSON keeps. A member declared `?: X | undefined` and passed an explicit
49
+ * `undefined` is refused; declare it `?: X` and omit the member instead. `unknown` passes through
50
+ * unvetted by the projection. For an `unknown` member, `roundTripJSON` refuses `undefined`,
51
+ * functions, symbols, and non-finite numbers at runtime; JSON otherwise may silently reshape the
52
+ * value, such as a `Date` to a string or a `Map` to `{}`.
53
+ * @example
54
+ * ```ts
55
+ * interface Snapshot {
56
+ * readonly id: string
57
+ * readonly turns: number
58
+ * }
59
+ *
60
+ * // { readonly id: string; readonly turns: number } — every member survives.
61
+ * type Safe = JSONSafe<Snapshot>
62
+ * ```
63
+ */
64
+ export declare type JSONSafe<T> = unknown extends T ? T : T extends string | number | boolean | null ? T : T extends ReadonlyArray<infer E> ? ReadonlyArray<JSONSafe<E>> : T extends (...args: never[]) => unknown ? never : T extends object ? object extends T ? never : {
65
+ readonly [K in keyof T]: K extends symbol ? never : JSONSafe<T[K]>;
66
+ } : never;
67
+
35
68
  /** Any value JSON can represent, so a round trip through JSON preserves the type. */
36
69
  export declare type JSONValue = string | number | boolean | null | readonly JSONValue[] | {
37
70
  readonly [key: string]: JSONValue;
@@ -75,13 +108,14 @@ export declare function resolveRoot(meta: ImportMeta): URL;
75
108
  /**
76
109
  * Copies a JSON value through serialization and parsing.
77
110
  *
78
- * @typeParam T - The JSON value type.
79
- * @param value - The value to copy.
111
+ * @typeParam T - The copied value's type, which the copy keeps.
112
+ * @param value - The value to copy, bounded by its own `JSONSafe` projection.
80
113
  * @returns The parsed JSON copy.
81
114
  * @remarks Non-finite numbers throw because JSON would replace them with `null`. Negative zero is
82
- * normalized to zero by JSON serialization.
115
+ * normalized to zero by JSON serialization. The bound intersects `JSONSafe<T>` rather than
116
+ * constraining `T` to `JSONValue`, so an interface-typed value round-trips.
83
117
  */
84
- export declare function roundTripJSON<T extends JSONValue>(value: T): T;
118
+ export declare function roundTripJSON<T>(value: T & JSONSafe<T>): T;
85
119
 
86
120
  /**
87
121
  * Waits for a host timer to elapse.
@@ -32,6 +32,39 @@ export declare function collectStream<T>(stream: ReadableStream<T>): Promise<rea
32
32
  */
33
33
  export declare function createRecorder<TArgs extends readonly unknown[]>(): RecorderInterface<TArgs>;
34
34
 
35
+ /**
36
+ * The JSON-safe projection of a type: every member JSON preserves, mapped to itself, and every
37
+ * member it does not, mapped to `never`.
38
+ *
39
+ * @typeParam T - The type to project.
40
+ * @remarks Intersect a parameter with this rather than constraining it to `JSONValue`. A `JSONValue`
41
+ * constraint rejects every `interface`, because TypeScript grants an implicit index signature to a
42
+ * type alias and never to an interface, and interfaces are what this project's public types are. A
43
+ * value whose type survives the projection satisfies the intersection unchanged; one that carries a
44
+ * method, a `Date`, a `Map`, the opaque `object` type, or a symbol-keyed member meets `never` at that
45
+ * member and is rejected there. A member typed `undefined` is rejected the same way, because
46
+ * serialization drops it from an object and rewrites it to `null` in an array, so the returned type
47
+ * would claim a member the copy does not carry. An optional member survives, since its declared type
48
+ * still narrows to what JSON keeps. A member declared `?: X | undefined` and passed an explicit
49
+ * `undefined` is refused; declare it `?: X` and omit the member instead. `unknown` passes through
50
+ * unvetted by the projection. For an `unknown` member, `roundTripJSON` refuses `undefined`,
51
+ * functions, symbols, and non-finite numbers at runtime; JSON otherwise may silently reshape the
52
+ * value, such as a `Date` to a string or a `Map` to `{}`.
53
+ * @example
54
+ * ```ts
55
+ * interface Snapshot {
56
+ * readonly id: string
57
+ * readonly turns: number
58
+ * }
59
+ *
60
+ * // { readonly id: string; readonly turns: number } — every member survives.
61
+ * type Safe = JSONSafe<Snapshot>
62
+ * ```
63
+ */
64
+ export declare type JSONSafe<T> = unknown extends T ? T : T extends string | number | boolean | null ? T : T extends ReadonlyArray<infer E> ? ReadonlyArray<JSONSafe<E>> : T extends (...args: never[]) => unknown ? never : T extends object ? object extends T ? never : {
65
+ readonly [K in keyof T]: K extends symbol ? never : JSONSafe<T[K]>;
66
+ } : never;
67
+
35
68
  /** Any value JSON can represent, so a round trip through JSON preserves the type. */
36
69
  export declare type JSONValue = string | number | boolean | null | readonly JSONValue[] | {
37
70
  readonly [key: string]: JSONValue;
@@ -75,13 +108,14 @@ export declare function resolveRoot(meta: ImportMeta): URL;
75
108
  /**
76
109
  * Copies a JSON value through serialization and parsing.
77
110
  *
78
- * @typeParam T - The JSON value type.
79
- * @param value - The value to copy.
111
+ * @typeParam T - The copied value's type, which the copy keeps.
112
+ * @param value - The value to copy, bounded by its own `JSONSafe` projection.
80
113
  * @returns The parsed JSON copy.
81
114
  * @remarks Non-finite numbers throw because JSON would replace them with `null`. Negative zero is
82
- * normalized to zero by JSON serialization.
115
+ * normalized to zero by JSON serialization. The bound intersects `JSONSafe<T>` rather than
116
+ * constraining `T` to `JSONValue`, so an interface-typed value round-trips.
83
117
  */
84
- export declare function roundTripJSON<T extends JSONValue>(value: T): T;
118
+ export declare function roundTripJSON<T>(value: T & JSONSafe<T>): T;
85
119
 
86
120
  /**
87
121
  * Waits for a host timer to elapse.
@@ -68,14 +68,16 @@ async function collectStream(stream) {
68
68
  /**
69
69
  * Copies a JSON value through serialization and parsing.
70
70
  *
71
- * @typeParam T - The JSON value type.
72
- * @param value - The value to copy.
71
+ * @typeParam T - The copied value's type, which the copy keeps.
72
+ * @param value - The value to copy, bounded by its own `JSONSafe` projection.
73
73
  * @returns The parsed JSON copy.
74
74
  * @remarks Non-finite numbers throw because JSON would replace them with `null`. Negative zero is
75
- * normalized to zero by JSON serialization.
75
+ * normalized to zero by JSON serialization. The bound intersects `JSONSafe<T>` rather than
76
+ * constraining `T` to `JSONValue`, so an interface-typed value round-trips.
76
77
  */
77
78
  function roundTripJSON(value) {
78
79
  const serialized = JSON.stringify(value, (_key, current) => {
80
+ if (current === void 0 || typeof current === "function" || typeof current === "symbol") throw new Error("JSON values must not contain undefined, functions, or symbols");
79
81
  if (typeof current === "number" && !Number.isFinite(current)) throw new Error("JSON values must contain finite numbers");
80
82
  return current;
81
83
  });
@@ -83,7 +85,6 @@ function roundTripJSON(value) {
83
85
  const pending = [parsed];
84
86
  while (pending.length > 0) {
85
87
  const current = pending.pop();
86
- if (current === void 0) continue;
87
88
  if (typeof current === "number" && !Number.isFinite(current)) throw new Error("JSON values must contain finite numbers");
88
89
  if (Array.isArray(current)) for (const child of current) pending.push(child);
89
90
  else if (typeof current === "object" && current !== null) for (const child of Object.values(current)) pending.push(child);
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../../src/core/helpers.ts","../../../src/core/factories.ts"],"sourcesContent":["import type { JSONValue } from './types.js'\n\n/**\n * Waits for a host timer to elapse.\n *\n * @param ms - The delay in milliseconds.\n * @returns A promise that resolves after the timer fires.\n */\nexport function waitForDelay(ms = 0): Promise<void> {\n\treturn new Promise((resolve) => setTimeout(resolve, ms))\n}\n\n/**\n * Captures the value thrown by a thunk.\n *\n * @param thunk - The work whose thrown value to capture.\n * @returns The thrown value, or `undefined` when the thunk completes.\n */\nexport function captureError(thunk: () => unknown): unknown {\n\ttry {\n\t\tthunk()\n\t} catch (error) {\n\t\treturn error\n\t}\n\treturn undefined\n}\n\n/**\n * Requires a value to be present.\n *\n * @typeParam T - The required value type.\n * @param value - The value to check.\n * @param message - The error message used when the value is absent.\n * @returns The present value.\n */\nexport function requireValue<T>(value: T | null | undefined, message = 'Value is required'): T {\n\tif (value === null || value === undefined) throw new Error(message)\n\treturn value\n}\n\n/**\n * Collects every value from an async iterable.\n *\n * @typeParam T - The yielded value type.\n * @param source - The async iterable to drain.\n * @returns The yielded values in iteration order.\n */\nexport async function collect<T>(source: AsyncIterable<T>): Promise<readonly T[]> {\n\tconst values: T[] = []\n\tfor await (const value of source) values.push(value)\n\treturn values\n}\n\n/**\n * Collects every value from a readable stream.\n *\n * @typeParam T - The streamed value type.\n * @param stream - The readable stream to drain.\n * @returns The streamed values in read order.\n */\nexport async function collectStream<T>(stream: ReadableStream<T>): Promise<readonly T[]> {\n\tconst reader = stream.getReader()\n\tconst values: T[] = []\n\ttry {\n\t\twhile (true) {\n\t\t\tconst result = await reader.read()\n\t\t\tif (result.done) return values\n\t\t\tvalues.push(result.value)\n\t\t}\n\t} finally {\n\t\treader.releaseLock()\n\t}\n}\n\n/**\n * Copies a JSON value through serialization and parsing.\n *\n * @typeParam T - The JSON value type.\n * @param value - The value to copy.\n * @returns The parsed JSON copy.\n * @remarks Non-finite numbers throw because JSON would replace them with `null`. Negative zero is\n * normalized to zero by JSON serialization.\n */\nexport function roundTripJSON<T extends JSONValue>(value: T): T {\n\tconst serialized = JSON.stringify(value, (_key, current) => {\n\t\tif (typeof current === 'number' && !Number.isFinite(current)) {\n\t\t\tthrow new Error('JSON values must contain finite numbers')\n\t\t}\n\t\treturn current\n\t})\n\tconst parsed: T = JSON.parse(serialized)\n\tconst pending: JSONValue[] = [parsed]\n\twhile (pending.length > 0) {\n\t\tconst current = pending.pop()\n\t\tif (current === undefined) continue\n\t\tif (typeof current === 'number' && !Number.isFinite(current)) {\n\t\t\tthrow new Error('JSON values must contain finite numbers')\n\t\t}\n\t\tif (Array.isArray(current)) {\n\t\t\tfor (const child of current) pending.push(child)\n\t\t} else if (typeof current === 'object' && current !== null) {\n\t\t\tfor (const child of Object.values(current)) pending.push(child)\n\t\t}\n\t}\n\treturn parsed\n}\n\n/**\n * Resolves the parent directory of a calling module, which is the workspace root when called from\n * the conventional `tests/setup.ts` location.\n *\n * @param meta - The calling module metadata.\n * @returns The root URL one directory above the calling file.\n */\nexport function resolveRoot(meta: ImportMeta): URL {\n\treturn new URL('../', meta.url)\n}\n","import type { RecorderInterface } from './types.js'\n\n/**\n * Creates a recorder for callback arguments.\n *\n * @typeParam TArgs - The argument tuple to record.\n * @returns A recorder whose handler appends calls in order.\n */\nexport function createRecorder<TArgs extends readonly unknown[]>(): RecorderInterface<TArgs> {\n\tconst calls: TArgs[] = []\n\treturn {\n\t\tcalls,\n\t\tget count() {\n\t\t\treturn calls.length\n\t\t},\n\t\thandler(...args) {\n\t\t\tcalls.push(args)\n\t\t},\n\t\tclear() {\n\t\t\tcalls.length = 0\n\t\t},\n\t}\n}\n"],"mappings":";;;;;;;AAQA,SAAgB,aAAa,KAAK,GAAkB;CACnD,OAAO,IAAI,SAAS,YAAY,WAAW,SAAS,EAAE,CAAC;AACxD;;;;;;;AAQA,SAAgB,aAAa,OAA+B;CAC3D,IAAI;EACH,MAAM;CACP,SAAS,OAAO;EACf,OAAO;CACR;AAED;;;;;;;;;AAUA,SAAgB,aAAgB,OAA6B,UAAU,qBAAwB;CAC9F,IAAI,UAAU,QAAQ,UAAU,KAAA,GAAW,MAAM,IAAI,MAAM,OAAO;CAClE,OAAO;AACR;;;;;;;;AASA,eAAsB,QAAW,QAAiD;CACjF,MAAM,SAAc,CAAC;CACrB,WAAW,MAAM,SAAS,QAAQ,OAAO,KAAK,KAAK;CACnD,OAAO;AACR;;;;;;;;AASA,eAAsB,cAAiB,QAAkD;CACxF,MAAM,SAAS,OAAO,UAAU;CAChC,MAAM,SAAc,CAAC;CACrB,IAAI;EACH,OAAO,MAAM;GACZ,MAAM,SAAS,MAAM,OAAO,KAAK;GACjC,IAAI,OAAO,MAAM,OAAO;GACxB,OAAO,KAAK,OAAO,KAAK;EACzB;CACD,UAAU;EACT,OAAO,YAAY;CACpB;AACD;;;;;;;;;;AAWA,SAAgB,cAAmC,OAAa;CAC/D,MAAM,aAAa,KAAK,UAAU,QAAQ,MAAM,YAAY;EAC3D,IAAI,OAAO,YAAY,YAAY,CAAC,OAAO,SAAS,OAAO,GAC1D,MAAM,IAAI,MAAM,yCAAyC;EAE1D,OAAO;CACR,CAAC;CACD,MAAM,SAAY,KAAK,MAAM,UAAU;CACvC,MAAM,UAAuB,CAAC,MAAM;CACpC,OAAO,QAAQ,SAAS,GAAG;EAC1B,MAAM,UAAU,QAAQ,IAAI;EAC5B,IAAI,YAAY,KAAA,GAAW;EAC3B,IAAI,OAAO,YAAY,YAAY,CAAC,OAAO,SAAS,OAAO,GAC1D,MAAM,IAAI,MAAM,yCAAyC;EAE1D,IAAI,MAAM,QAAQ,OAAO,GACxB,KAAK,MAAM,SAAS,SAAS,QAAQ,KAAK,KAAK;OACzC,IAAI,OAAO,YAAY,YAAY,YAAY,MACrD,KAAK,MAAM,SAAS,OAAO,OAAO,OAAO,GAAG,QAAQ,KAAK,KAAK;CAEhE;CACA,OAAO;AACR;;;;;;;;AASA,SAAgB,YAAY,MAAuB;CAClD,OAAO,IAAI,IAAI,OAAO,KAAK,GAAG;AAC/B;;;;;;;;;AC5GA,SAAgB,iBAA6E;CAC5F,MAAM,QAAiB,CAAC;CACxB,OAAO;EACN;EACA,IAAI,QAAQ;GACX,OAAO,MAAM;EACd;EACA,QAAQ,GAAG,MAAM;GAChB,MAAM,KAAK,IAAI;EAChB;EACA,QAAQ;GACP,MAAM,SAAS;EAChB;CACD;AACD"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../../src/core/helpers.ts","../../../src/core/factories.ts"],"sourcesContent":["import type { JSONSafe } from './types.js'\n\n/**\n * Waits for a host timer to elapse.\n *\n * @param ms - The delay in milliseconds.\n * @returns A promise that resolves after the timer fires.\n */\nexport function waitForDelay(ms = 0): Promise<void> {\n\treturn new Promise((resolve) => setTimeout(resolve, ms))\n}\n\n/**\n * Captures the value thrown by a thunk.\n *\n * @param thunk - The work whose thrown value to capture.\n * @returns The thrown value, or `undefined` when the thunk completes.\n */\nexport function captureError(thunk: () => unknown): unknown {\n\ttry {\n\t\tthunk()\n\t} catch (error) {\n\t\treturn error\n\t}\n\treturn undefined\n}\n\n/**\n * Requires a value to be present.\n *\n * @typeParam T - The required value type.\n * @param value - The value to check.\n * @param message - The error message used when the value is absent.\n * @returns The present value.\n */\nexport function requireValue<T>(value: T | null | undefined, message = 'Value is required'): T {\n\tif (value === null || value === undefined) throw new Error(message)\n\treturn value\n}\n\n/**\n * Collects every value from an async iterable.\n *\n * @typeParam T - The yielded value type.\n * @param source - The async iterable to drain.\n * @returns The yielded values in iteration order.\n */\nexport async function collect<T>(source: AsyncIterable<T>): Promise<readonly T[]> {\n\tconst values: T[] = []\n\tfor await (const value of source) values.push(value)\n\treturn values\n}\n\n/**\n * Collects every value from a readable stream.\n *\n * @typeParam T - The streamed value type.\n * @param stream - The readable stream to drain.\n * @returns The streamed values in read order.\n */\nexport async function collectStream<T>(stream: ReadableStream<T>): Promise<readonly T[]> {\n\tconst reader = stream.getReader()\n\tconst values: T[] = []\n\ttry {\n\t\twhile (true) {\n\t\t\tconst result = await reader.read()\n\t\t\tif (result.done) return values\n\t\t\tvalues.push(result.value)\n\t\t}\n\t} finally {\n\t\treader.releaseLock()\n\t}\n}\n\n/**\n * Copies a JSON value through serialization and parsing.\n *\n * @typeParam T - The copied value's type, which the copy keeps.\n * @param value - The value to copy, bounded by its own `JSONSafe` projection.\n * @returns The parsed JSON copy.\n * @remarks Non-finite numbers throw because JSON would replace them with `null`. Negative zero is\n * normalized to zero by JSON serialization. The bound intersects `JSONSafe<T>` rather than\n * constraining `T` to `JSONValue`, so an interface-typed value round-trips.\n */\nexport function roundTripJSON<T>(value: T & JSONSafe<T>): T {\n\tconst serialized = JSON.stringify(value, (_key, current) => {\n\t\tif (current === undefined || typeof current === 'function' || typeof current === 'symbol') {\n\t\t\tthrow new Error('JSON values must not contain undefined, functions, or symbols')\n\t\t}\n\t\tif (typeof current === 'number' && !Number.isFinite(current)) {\n\t\t\tthrow new Error('JSON values must contain finite numbers')\n\t\t}\n\t\treturn current\n\t})\n\tconst parsed: T = JSON.parse(serialized)\n\tconst pending: unknown[] = [parsed]\n\twhile (pending.length > 0) {\n\t\tconst current = pending.pop()\n\t\tif (typeof current === 'number' && !Number.isFinite(current)) {\n\t\t\tthrow new Error('JSON values must contain finite numbers')\n\t\t}\n\t\tif (Array.isArray(current)) {\n\t\t\tfor (const child of current) pending.push(child)\n\t\t} else if (typeof current === 'object' && current !== null) {\n\t\t\tfor (const child of Object.values(current)) pending.push(child)\n\t\t}\n\t}\n\treturn parsed\n}\n\n/**\n * Resolves the parent directory of a calling module, which is the workspace root when called from\n * the conventional `tests/setup.ts` location.\n *\n * @param meta - The calling module metadata.\n * @returns The root URL one directory above the calling file.\n */\nexport function resolveRoot(meta: ImportMeta): URL {\n\treturn new URL('../', meta.url)\n}\n","import type { RecorderInterface } from './types.js'\n\n/**\n * Creates a recorder for callback arguments.\n *\n * @typeParam TArgs - The argument tuple to record.\n * @returns A recorder whose handler appends calls in order.\n */\nexport function createRecorder<TArgs extends readonly unknown[]>(): RecorderInterface<TArgs> {\n\tconst calls: TArgs[] = []\n\treturn {\n\t\tcalls,\n\t\tget count() {\n\t\t\treturn calls.length\n\t\t},\n\t\thandler(...args) {\n\t\t\tcalls.push(args)\n\t\t},\n\t\tclear() {\n\t\t\tcalls.length = 0\n\t\t},\n\t}\n}\n"],"mappings":";;;;;;;AAQA,SAAgB,aAAa,KAAK,GAAkB;CACnD,OAAO,IAAI,SAAS,YAAY,WAAW,SAAS,EAAE,CAAC;AACxD;;;;;;;AAQA,SAAgB,aAAa,OAA+B;CAC3D,IAAI;EACH,MAAM;CACP,SAAS,OAAO;EACf,OAAO;CACR;AAED;;;;;;;;;AAUA,SAAgB,aAAgB,OAA6B,UAAU,qBAAwB;CAC9F,IAAI,UAAU,QAAQ,UAAU,KAAA,GAAW,MAAM,IAAI,MAAM,OAAO;CAClE,OAAO;AACR;;;;;;;;AASA,eAAsB,QAAW,QAAiD;CACjF,MAAM,SAAc,CAAC;CACrB,WAAW,MAAM,SAAS,QAAQ,OAAO,KAAK,KAAK;CACnD,OAAO;AACR;;;;;;;;AASA,eAAsB,cAAiB,QAAkD;CACxF,MAAM,SAAS,OAAO,UAAU;CAChC,MAAM,SAAc,CAAC;CACrB,IAAI;EACH,OAAO,MAAM;GACZ,MAAM,SAAS,MAAM,OAAO,KAAK;GACjC,IAAI,OAAO,MAAM,OAAO;GACxB,OAAO,KAAK,OAAO,KAAK;EACzB;CACD,UAAU;EACT,OAAO,YAAY;CACpB;AACD;;;;;;;;;;;AAYA,SAAgB,cAAiB,OAA2B;CAC3D,MAAM,aAAa,KAAK,UAAU,QAAQ,MAAM,YAAY;EAC3D,IAAI,YAAY,KAAA,KAAa,OAAO,YAAY,cAAc,OAAO,YAAY,UAChF,MAAM,IAAI,MAAM,+DAA+D;EAEhF,IAAI,OAAO,YAAY,YAAY,CAAC,OAAO,SAAS,OAAO,GAC1D,MAAM,IAAI,MAAM,yCAAyC;EAE1D,OAAO;CACR,CAAC;CACD,MAAM,SAAY,KAAK,MAAM,UAAU;CACvC,MAAM,UAAqB,CAAC,MAAM;CAClC,OAAO,QAAQ,SAAS,GAAG;EAC1B,MAAM,UAAU,QAAQ,IAAI;EAC5B,IAAI,OAAO,YAAY,YAAY,CAAC,OAAO,SAAS,OAAO,GAC1D,MAAM,IAAI,MAAM,yCAAyC;EAE1D,IAAI,MAAM,QAAQ,OAAO,GACxB,KAAK,MAAM,SAAS,SAAS,QAAQ,KAAK,KAAK;OACzC,IAAI,OAAO,YAAY,YAAY,YAAY,MACrD,KAAK,MAAM,SAAS,OAAO,OAAO,OAAO,GAAG,QAAQ,KAAK,KAAK;CAEhE;CACA,OAAO;AACR;;;;;;;;AASA,SAAgB,YAAY,MAAuB;CAClD,OAAO,IAAI,IAAI,OAAO,KAAK,GAAG;AAC/B;;;;;;;;;AC/GA,SAAgB,iBAA6E;CAC5F,MAAM,QAAiB,CAAC;CACxB,OAAO;EACN;EACA,IAAI,QAAQ;GACX,OAAO,MAAM;EACd;EACA,QAAQ,GAAG,MAAM;GAChB,MAAM,KAAK,IAAI;EAChB;EACA,QAAQ;GACP,MAAM,SAAS;EAChB;CACD;AACD"}
@@ -18,36 +18,72 @@ function resolveContained(root, target) {
18
18
  return candidate;
19
19
  }
20
20
  /**
21
- * Reads files from selected directories below a root directory.
21
+ * Reports whether two directory identities name the same allocation.
22
+ *
23
+ * @param current - The identity read from the path now.
24
+ * @param allocation - The identity recorded when the directory was allocated.
25
+ * @returns Whether the device, the index node, and the creation time all match.
26
+ * @remarks All three fields are compared because none of them alone identifies an allocation. A
27
+ * device is shared by every directory on one filesystem, an index node is reused once its directory
28
+ * is removed, and a creation time repeats within the host's timestamp resolution.
29
+ */
30
+ function matchesIdentity(current, allocation) {
31
+ return current.device === allocation.device && current.inode === allocation.inode && current.birth === allocation.birth;
32
+ }
33
+ /**
34
+ * Reports whether a root-relative key matches an exclusion.
35
+ *
36
+ * @param key - The root-relative key to test.
37
+ * @param exclusions - The normalized root-relative exclusion keys.
38
+ * @returns Whether an exclusion names the key or one of its ancestors.
39
+ */
40
+ function isExcluded(key, exclusions) {
41
+ return exclusions.some((rule) => rule === "" || key === rule || key.startsWith(`${rule}/`));
42
+ }
43
+ /**
44
+ * Reads files from selected targets below a root directory.
22
45
  *
23
46
  * @param root - The root directory as a path or file URL.
24
- * @param directories - The directories to visit below the root.
25
- * @param options - Optional file extension and exact-path exclusions.
47
+ * @param targets - The files to read directly and directories to visit below the root.
48
+ * @param options - Optional file extension and path exclusions.
26
49
  * @returns File contents keyed by sorted root-relative paths.
27
- * @remarks An absent extension filter includes every file. Exclusions match full root-relative keys.
50
+ * @throws When the root or a named target is a symbolic link, is not a supported entry, or resolves
51
+ * outside the root.
52
+ * @remarks A named file is included regardless of the extension filter. An absent extension filter
53
+ * includes every walked file. An exclusion matches whole root-relative key segments and covers every
54
+ * key below it, and it applies to a named target and a walked entry alike.
28
55
  */
29
- function readInventory(root, directories, options) {
56
+ function readInventory(root, targets, options) {
30
57
  const supplied = (0, node_path.resolve)(typeof root === "string" ? root : (0, node_url.fileURLToPath)(root));
31
58
  const rootStatus = (0, node_fs.lstatSync)(supplied);
32
59
  if (rootStatus.isSymbolicLink()) throw new Error("Root is a symbolic link");
33
60
  if (!rootStatus.isDirectory()) throw new Error("Root is not a directory");
34
61
  const base = node_fs.realpathSync.native(supplied);
35
- if (directories.length === 0) return Object.fromEntries([]);
36
- const excluded = new Set(options?.exclude);
62
+ if (targets.length === 0) return Object.fromEntries([]);
63
+ const exclusions = (options?.exclude ?? []).map((rule) => {
64
+ const collapsed = (rule.startsWith("./") ? rule.slice(2) : rule).replace(/\/+/g, "/");
65
+ const untrailed = collapsed.endsWith("/") ? collapsed.slice(0, -1) : collapsed;
66
+ return untrailed === "." ? "" : untrailed;
67
+ });
37
68
  const pending = [];
38
69
  const queued = /* @__PURE__ */ new Set();
39
70
  const contents = /* @__PURE__ */ new Map();
40
- for (const directory of directories) {
41
- const candidate = resolveContained(base, directory);
42
- if (candidate === void 0) throw new Error(`Directory outside root: ${directory}`);
71
+ for (const target of targets) {
72
+ const candidate = resolveContained(base, target);
73
+ if (candidate === void 0) throw new Error(`Target outside root: ${target}`);
43
74
  const status = (0, node_fs.lstatSync)(candidate);
44
- if (status.isSymbolicLink()) throw new Error(`Directory is a symbolic link: ${directory}`);
45
- if (!status.isDirectory()) throw new Error(`Not a directory: ${directory}`);
75
+ if (status.isSymbolicLink()) throw new Error(`Target is a symbolic link: ${target}`);
76
+ if (!status.isDirectory() && !status.isFile()) throw new Error(`Target is not a file or directory: ${target}`);
46
77
  const physical = node_fs.realpathSync.native(candidate);
47
78
  const resolved = resolveContained(base, (0, node_path.relative)(base, physical));
48
- if (resolved === void 0) throw new Error(`Directory outside root: ${directory}`);
79
+ if (resolved === void 0) throw new Error(`Target outside root: ${target}`);
49
80
  const key = (0, node_path.relative)(base, resolved).split(node_path.sep).join("/");
50
- if (excluded.has(key) || queued.has(physical)) continue;
81
+ if (isExcluded(key, exclusions)) continue;
82
+ if (status.isFile()) {
83
+ contents.set(key, (0, node_fs.readFileSync)(physical, "utf8"));
84
+ continue;
85
+ }
86
+ if (queued.has(physical)) continue;
51
87
  queued.add(physical);
52
88
  pending.push(physical);
53
89
  }
@@ -59,7 +95,7 @@ function readInventory(root, directories, options) {
59
95
  const status = (0, node_fs.lstatSync)(path);
60
96
  if (status.isSymbolicLink()) continue;
61
97
  const key = (0, node_path.relative)(base, path).split(node_path.sep).join("/");
62
- if (excluded.has(key)) continue;
98
+ if (isExcluded(key, exclusions)) continue;
63
99
  if (status.isDirectory()) {
64
100
  const physical = node_fs.realpathSync.native(path);
65
101
  if (resolveContained(base, (0, node_path.relative)(base, physical)) === void 0 || queued.has(physical)) continue;
@@ -78,17 +114,30 @@ function readInventory(root, directories, options) {
78
114
  /**
79
115
  * Allocates an owned temporary directory with contained file operations.
80
116
  *
81
- * @param options - Optional directory prefix and initial files.
117
+ * @param options - Optional parent directory, name prefix, and initial files.
82
118
  * @returns The scratch directory and its file operations.
83
- * @remarks The prefix defaults to `orkestrel-test-`. Seed keys use root-relative paths.
119
+ * @throws When the parent is missing, a symbolic link, or not a directory; when the prefix contains
120
+ * `/` or `\`; or when allocation or seeding fails.
121
+ * @remarks The parent defaults to the host temporary directory. The prefix defaults to
122
+ * `orkestrel-test-`. Seed keys use root-relative paths.
84
123
  */
85
124
  function createScratch(options) {
86
- const temporary = (0, node_path.resolve)((0, node_os.tmpdir)());
87
- const prefix = (0, node_path.resolve)(temporary, options?.prefix ?? "orkestrel-test-");
88
- if ((0, node_path.dirname)(prefix) !== temporary) throw new Error("Scratch prefix must stay within the temporary directory");
89
- const path = (0, node_fs.mkdtempSync)(prefix);
90
- const allocation = (0, node_fs.statSync)(path);
125
+ const parent = (0, node_path.resolve)(options?.parent ?? (0, node_os.tmpdir)());
126
+ const parentStatus = (0, node_fs.lstatSync)(parent, { throwIfNoEntry: false });
127
+ if (parentStatus === void 0) throw new Error("Scratch parent does not exist");
128
+ if (parentStatus.isSymbolicLink()) throw new Error("Scratch parent is a symbolic link");
129
+ if (!parentStatus.isDirectory()) throw new Error("Scratch parent is not a directory");
130
+ const prefix = options?.prefix ?? "orkestrel-test-";
131
+ if (prefix.includes("/") || prefix.includes("\\")) throw new Error("Scratch prefix must be a name fragment");
132
+ const path = (0, node_fs.mkdtempSync)(`${parent}${node_path.sep}${prefix}`);
133
+ const allocated = (0, node_fs.statSync)(path);
134
+ const allocation = {
135
+ birth: allocated.birthtimeMs,
136
+ device: allocated.dev,
137
+ inode: allocated.ino
138
+ };
91
139
  const outside = "Path outside scratch directory";
140
+ const unremovable = "Scratch directory is not a removable target";
92
141
  try {
93
142
  for (const [target, text] of Object.entries(options?.files ?? {})) {
94
143
  const candidate = resolveContained(path, target);
@@ -108,23 +157,20 @@ function createScratch(options) {
108
157
  write(target, text) {
109
158
  const candidate = resolveContained(path, target);
110
159
  if (candidate === void 0) throw new Error(`${outside}: ${target}`);
111
- const rootStatus = (0, node_fs.lstatSync)(path, { throwIfNoEntry: false });
112
- if (rootStatus === void 0) throw new Error("Scratch directory does not exist");
113
- if (rootStatus.isSymbolicLink()) throw new Error("Scratch directory is a symbolic link");
114
- if (!rootStatus.isDirectory()) throw new Error("Scratch path is not a directory");
160
+ if (!scratch.has(".")) throw new Error("Scratch directory does not exist");
115
161
  (0, node_fs.mkdirSync)((0, node_path.dirname)(candidate), { recursive: true });
116
162
  (0, node_fs.writeFileSync)(candidate, text);
117
163
  },
118
164
  read(target) {
119
165
  const candidate = resolveContained(path, target);
120
166
  if (candidate === void 0) throw new Error(`${outside}: ${target}`);
121
- if (!scratch.exists(target)) return void 0;
167
+ if (!scratch.has(target)) return void 0;
122
168
  const status = (0, node_fs.statSync)(candidate, { throwIfNoEntry: false });
123
169
  if (status === void 0) return void 0;
124
170
  if (status.isDirectory()) throw new Error(`Scratch path is a directory: ${target}`);
125
171
  return (0, node_fs.readFileSync)(candidate, "utf8");
126
172
  },
127
- exists(target) {
173
+ has(target) {
128
174
  const candidate = resolveContained(path, target);
129
175
  if (candidate === void 0) throw new Error(`${outside}: ${target}`);
130
176
  const rootStatus = (0, node_fs.lstatSync)(path, { throwIfNoEntry: false });
@@ -133,9 +179,57 @@ function createScratch(options) {
133
179
  if (!rootStatus.isDirectory()) throw new Error("Scratch path is not a directory");
134
180
  return (0, node_fs.lstatSync)(candidate, { throwIfNoEntry: false }) !== void 0;
135
181
  },
182
+ names(target = ".") {
183
+ const candidate = resolveContained(path, target);
184
+ if (candidate === void 0) throw new Error(`${outside}: ${target}`);
185
+ if (!scratch.has(".")) throw new Error("Scratch directory does not exist");
186
+ const status = (0, node_fs.statSync)(candidate, { throwIfNoEntry: false });
187
+ if (status === void 0) throw new Error(`Scratch path does not exist: ${target}`);
188
+ if (!status.isDirectory()) throw new Error(`Scratch path is not a directory: ${target}`);
189
+ return (0, node_fs.readdirSync)(candidate).sort();
190
+ },
191
+ ensure(target) {
192
+ const candidate = resolveContained(path, target);
193
+ if (candidate === void 0) throw new Error(`${outside}: ${target}`);
194
+ if (!scratch.has(".")) throw new Error("Scratch directory does not exist");
195
+ const status = (0, node_fs.statSync)(candidate, { throwIfNoEntry: false });
196
+ if (status !== void 0 && !status.isDirectory()) throw new Error(`Scratch path is not a directory: ${target}`);
197
+ if (status === void 0) (0, node_fs.mkdirSync)(candidate, { recursive: true });
198
+ return candidate;
199
+ },
200
+ link(target, source) {
201
+ const candidate = resolveContained(path, target);
202
+ if (candidate === void 0) throw new Error(`${outside}: ${target}`);
203
+ if (!scratch.has(".")) throw new Error("Scratch directory does not exist");
204
+ (0, node_fs.mkdirSync)((0, node_path.dirname)(candidate), { recursive: true });
205
+ (0, node_fs.symlinkSync)(source, candidate);
206
+ },
207
+ remove(target) {
208
+ const candidate = resolveContained(path, target);
209
+ if (candidate === void 0) throw new Error(`${outside}: ${target}`);
210
+ if (candidate === path) throw new Error(`${unremovable}: ${target}`);
211
+ if (!scratch.has(".")) throw new Error("Scratch directory does not exist");
212
+ const status = (0, node_fs.lstatSync)(candidate, { throwIfNoEntry: false });
213
+ if (status !== void 0) {
214
+ if (matchesIdentity({
215
+ birth: status.birthtimeMs,
216
+ device: status.dev,
217
+ inode: status.ino
218
+ }, allocation)) throw new Error(`${unremovable}: ${target}`);
219
+ }
220
+ (0, node_fs.rmSync)(candidate, {
221
+ force: true,
222
+ recursive: true
223
+ });
224
+ },
136
225
  destroy() {
137
226
  const status = (0, node_fs.lstatSync)(path, { throwIfNoEntry: false });
138
- if (status === void 0 || status.dev !== allocation.dev || status.ino !== allocation.ino || status.birthtimeMs !== allocation.birthtimeMs) return;
227
+ if (status === void 0) return;
228
+ if (!matchesIdentity({
229
+ birth: status.birthtimeMs,
230
+ device: status.dev,
231
+ inode: status.ino
232
+ }, allocation)) return;
139
233
  (0, node_fs.rmSync)(path, {
140
234
  force: true,
141
235
  recursive: true
@@ -146,6 +240,8 @@ function createScratch(options) {
146
240
  }
147
241
  //#endregion
148
242
  exports.createScratch = createScratch;
243
+ exports.isExcluded = isExcluded;
244
+ exports.matchesIdentity = matchesIdentity;
149
245
  exports.readInventory = readInventory;
150
246
  exports.resolveContained = resolveContained;
151
247