@effected/workspaces 0.15.1 → 0.17.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.
package/README.md CHANGED
@@ -218,6 +218,25 @@ const packages = root === null ? [] : getWorkspacePackagesSync(root, options);
218
218
  // packages: the discovered workspace packages, empty when there is no root
219
219
  ```
220
220
 
221
+ Those four operations are the whole requirement. A fifth, `readDirectoryWithTypes`, is optional: supply it and package enumeration reads a directory's entries and their types in one call instead of a `readDirectory` plus an `isDirectory` per entry, which on a large workspace is a syscall per file. `nodeFileSystem` already implements it over `readdirSync(p, { withFileTypes: true })`, so the `node-sync` bindings get the fast path for free. Omit it and enumeration falls back to the four required operations with identical results — a cost optimization, never a behavior switch.
222
+
223
+ Each entry reports `name`, `isDirectory` and `isSymbolicLink` as a `SyncDirectoryEntry`, which Node's `Dirent` satisfies once its predicate methods are called. The link flag is load-bearing: a `Dirent` describes the entry itself, so a symlink pointing at a directory reports `isDirectory: false`, while the `stat`-based path resolves the link and calls the same entry a directory. Enumeration re-resolves links through `isDirectory` rather than trusting the flag, which is what keeps a workspace with symlinked packages discovered identically on both paths.
224
+
225
+ ```ts
226
+ import { readdirSync } from "node:fs";
227
+ import type { SyncDirectoryEntry } from "@effected/workspaces";
228
+
229
+ const readDirectoryWithTypes = (p: string): ReadonlyArray<SyncDirectoryEntry> =>
230
+ readdirSync(p, { withFileTypes: true }).map((entry) => ({
231
+ name: entry.name,
232
+ isDirectory: entry.isDirectory(),
233
+ isSymbolicLink: entry.isSymbolicLink(),
234
+ }));
235
+ // pass alongside the four required operations: { ...options.fileSystem, readDirectoryWithTypes }
236
+ ```
237
+
238
+ For a test fake, `@effected/memfs`' `MemoryFileSystem.syncFileSystem(volume)` satisfies `SyncFileSystem` structurally — neither package imports the other — so a config-time discovery path can be exercised against a virtual workspace with nothing on disk.
239
+
221
240
  Windows correctness is therefore the operations you pass, and nothing else. Both entry points drive one traversal state machine (the same dequeue order, depth rule, visit budget and `node_modules` prune), so the sync and Effect surfaces can never disagree about what a pattern means. The one deliberate difference is at a bound: the Effect enumerator fails typed, the sync one truncates. Prefer the Effect API everywhere you can run one.
222
241
 
223
242
  ## Error handling
@@ -286,7 +305,7 @@ A name miss in the derived `getPackage` fails with the service's own typed `Pack
286
305
  - `ReleaseTag` / `TrackingTag` — release-tag formatting (`ReleaseTag.single` / `.scoped`, strict SemVer by default with no `v` prefix) and the floating major/minor alias derivation GitHub Actions-style consumers expect (`v1`, `v1.2`), plus `classifyTag` to tell a release tag from a tracking alias.
287
306
  - `VersioningStrategy` — classify a workspace as `single`, `fixed-group` or `independent` from package names and fixed groups, or detect it live against `PublishabilityDetector`, and produce the release tags for a batch with `tagsFor`.
288
307
  - `findWorkspaceRootSync` / `getWorkspacePackagesSync` — the synchronous escape hatch for config-time callers that cannot await, over file and path operations you supply.
289
- - `@effected/workspaces/node-sync` — a second entry point holding the Node bindings for those operations (`nodeFileSystem`, `nodePath` and the `nodeSyncOps` bag), kept off the main entry so `node:*` never reaches a consumer that supplies its own.
308
+ - `@effected/workspaces/node-sync` — a second entry point holding the Node bindings for those operations (`nodeFileSystem`, `nodePath` and the `nodeSyncOps` bag), kept off the main entry so `node:*` never reaches a consumer that supplies its own. `nodeFileSystem` implements the optional `readDirectoryWithTypes` fast path, so the bindings enumerate a workspace in one `readdirSync` per directory.
290
309
 
291
310
  ## License
292
311
 
@@ -444,7 +444,8 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
444
444
  }),
445
445
  importerVersions: Effect.fn("WorkspaceCatalogs.importerVersions")(function* () {
446
446
  return (yield* memo).importerVersions;
447
- })
447
+ }),
448
+ refresh: () => invalidate
448
449
  };
449
450
  });
450
451
  /**
@@ -509,6 +510,11 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
509
510
  * the importer index from the lockfile's importer blocks — neither is in a
510
511
  * `CatalogSet`) and always die unless stubbed.
511
512
  *
513
+ * `refresh` defaults to `Effect.void` honestly: the double holds no memo,
514
+ * so "drop the memoized assembly" is genuinely a no-op — the stubs answer
515
+ * fresh on every call already. This mirrors `WorkspaceDiscovery.makeTest`'s
516
+ * `refresh`.
517
+ *
512
518
  * @example
513
519
  * ```ts
514
520
  * import { CatalogSet, WorkspaceCatalogs } from "@effected/workspaces";
@@ -528,6 +534,7 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
528
534
  peerDependencyRules: () => unstubbed("peerDependencyRules"),
529
535
  releaseAgeGate: () => unstubbed("releaseAgeGate"),
530
536
  importerVersions: () => unstubbed("importerVersions"),
537
+ refresh: () => Effect.void,
531
538
  ...overrides
532
539
  };
533
540
  };
package/WorkspacesSync.js CHANGED
@@ -35,6 +35,37 @@ const isDirectory = (fileSystem, dir) => {
35
35
  return false;
36
36
  }
37
37
  };
38
+ /**
39
+ * The entries of `absoluteDir`, each carrying whether it is a directory, or
40
+ * `undefined` when the directory could not be read at all (the degraded-skip
41
+ * case). Uses {@link SyncFileSystem.readDirectoryWithTypes} when the consumer
42
+ * supplied it and the four-operation fallback otherwise — the two agree by
43
+ * construction, including on symbolic links, which are always re-resolved
44
+ * through `isDirectory` because a `Dirent` describes the link and not its
45
+ * target.
46
+ */
47
+ const readResolvedEntries = (options, absoluteDir) => {
48
+ const { fileSystem, path } = options;
49
+ const withTypes = fileSystem.readDirectoryWithTypes;
50
+ if (withTypes !== void 0) try {
51
+ return withTypes(absoluteDir).map((entry) => ({
52
+ name: entry.name,
53
+ directory: entry.isSymbolicLink ? isDirectory(fileSystem, path.join(absoluteDir, entry.name)) : entry.isDirectory
54
+ }));
55
+ } catch {
56
+ return;
57
+ }
58
+ let names;
59
+ try {
60
+ names = fileSystem.readDirectory(absoluteDir);
61
+ } catch {
62
+ return;
63
+ }
64
+ return names.map((name) => ({
65
+ name,
66
+ directory: isDirectory(fileSystem, path.join(absoluteDir, name))
67
+ }));
68
+ };
38
69
  /** Whether `dir` holds a `package.json`. */
39
70
  const isPackage = (options, dir) => options.fileSystem.exists(options.path.join(dir, "package.json"));
40
71
  /**
@@ -212,17 +243,13 @@ const getWorkspacePackagesSync = (root, options) => {
212
243
  let stopped = false;
213
244
  for (let current = traversal.next(); current !== void 0 && !stopped; current = traversal.next()) {
214
245
  if (traversal.charge() !== void 0) break;
215
- let entries = [];
216
- try {
217
- entries = fileSystem.readDirectory(current.absolute);
218
- } catch {
219
- continue;
220
- }
246
+ const entries = readResolvedEntries(options, current.absolute);
247
+ if (entries === void 0) continue;
221
248
  for (const entry of entries) {
222
- if (isPruned(entry)) continue;
223
- const relative = joinRelative(current.relative, entry);
224
- const absolute = path.join(current.absolute, entry);
225
- if (!isDirectory(fileSystem, absolute)) continue;
249
+ if (isPruned(entry.name)) continue;
250
+ const relative = joinRelative(current.relative, entry.name);
251
+ const absolute = path.join(current.absolute, entry.name);
252
+ if (!entry.directory) continue;
226
253
  if (wildcard.crossesSegments && !traversal.admits(current)) {
227
254
  stopped = true;
228
255
  break;
package/index.d.ts CHANGED
@@ -2449,6 +2449,26 @@ interface WorkspaceCatalogsShape {
2449
2449
  * nothing rather than failing.
2450
2450
  */
2451
2451
  readonly importerVersions: () => Effect.Effect<ImporterVersions, CatalogAssemblyFailure>;
2452
+ /**
2453
+ * Discard the memoized assembly so the **next** read re-assembles — the same
2454
+ * single read and hook replay as the first, over the workspace as it stands
2455
+ * then.
2456
+ *
2457
+ * @remarks
2458
+ * The explicit memoization boundary for a tool that **mutates the workspace
2459
+ * mid-run** — installs, bumps a config dependency, regenerates the lockfile.
2460
+ * Release-age gating wants the before-state, a post-install peer check the
2461
+ * after-state, and one infinite memo cannot serve both without this call in
2462
+ * between; without it, `peerDependencyRules` keeps answering from the
2463
+ * pre-mutation hook replay and the checker reports findings the new rules
2464
+ * suppress.
2465
+ *
2466
+ * Unconditional and infallible: the memo is already success-only (a failed
2467
+ * or interrupted assembly retries by itself), so `refresh` exists solely to
2468
+ * discard a *successful* assembly that mutation has made stale. Calling it
2469
+ * before any read is harmless.
2470
+ */
2471
+ readonly refresh: () => Effect.Effect<void>;
2452
2472
  }
2453
2473
  /**
2454
2474
  * Options for the {@link WorkspaceCatalogs} layer.
@@ -2554,6 +2574,11 @@ declare class WorkspaceCatalogs extends WorkspaceCatalogs_base {
2554
2574
  * the importer index from the lockfile's importer blocks — neither is in a
2555
2575
  * `CatalogSet`) and always die unless stubbed.
2556
2576
  *
2577
+ * `refresh` defaults to `Effect.void` honestly: the double holds no memo,
2578
+ * so "drop the memoized assembly" is genuinely a no-op — the stubs answer
2579
+ * fresh on every call already. This mirrors `WorkspaceDiscovery.makeTest`'s
2580
+ * `refresh`.
2581
+ *
2557
2582
  * @example
2558
2583
  * ```ts
2559
2584
  * import { CatalogSet, WorkspaceCatalogs } from "@effected/workspaces";
@@ -3227,6 +3252,53 @@ interface SyncFileSystem {
3227
3252
  readonly readDirectory: (path: string) => ReadonlyArray<string>;
3228
3253
  /** Whether `path` is a directory. May throw; a throw reads as `false`. */
3229
3254
  readonly isDirectory: (path: string) => boolean;
3255
+ /**
3256
+ * Optional fast path: the entries inside `path` with their types already
3257
+ * known, in ONE call.
3258
+ *
3259
+ * @remarks
3260
+ * Package enumeration otherwise costs a `readDirectory` plus one
3261
+ * `isDirectory` per entry — the readdir-then-stat-per-entry shape, which on
3262
+ * a large workspace is a syscall per file. Supplying this collapses that to
3263
+ * a single `readdirSync(path, { withFileTypes: true })`; `nodeFileSystem`
3264
+ * does. Omit it and enumeration falls back to the four required operations
3265
+ * with identical results, so this is purely a cost optimization and never a
3266
+ * behavior switch.
3267
+ *
3268
+ * May throw; a throw skips the directory, exactly like `readDirectory`.
3269
+ */
3270
+ readonly readDirectoryWithTypes?: ((path: string) => ReadonlyArray<SyncDirectoryEntry>) | undefined;
3271
+ }
3272
+ /**
3273
+ * One directory entry with its type resolved, as the optional
3274
+ * {@link SyncFileSystem.readDirectoryWithTypes} fast path reports it. Node's
3275
+ * `Dirent` satisfies it after mapping its predicate methods to booleans.
3276
+ *
3277
+ * @remarks
3278
+ * `isSymbolicLink` is not decoration. A `Dirent` describes the entry ITSELF, so
3279
+ * a symbolic link pointing at a directory reports `isDirectory: false` — while
3280
+ * the `stat`-based slow path, which resolves the link, calls the same entry a
3281
+ * directory. Enumeration therefore re-resolves links through `isDirectory`
3282
+ * rather than trusting `isDirectory` on a link, which is what keeps the fast
3283
+ * and slow paths in agreement on a workspace whose packages are symlinked.
3284
+ *
3285
+ * Which behavior is *correct* is domain-dependent, so the flag is reported
3286
+ * rather than resolved away. Enumeration follows links because a symlinked
3287
+ * package is still a package. A test-file discovery walk usually must NOT: in a
3288
+ * pnpm workspace `node_modules` is a farm of links into the content-addressed
3289
+ * store, and following them walks the whole store or hits a cycle. A consumer
3290
+ * building its own walker on this shape wants `isDirectory` verbatim — there,
3291
+ * not following is the requirement, not the hazard.
3292
+ *
3293
+ * @public
3294
+ */
3295
+ interface SyncDirectoryEntry {
3296
+ /** The entry's own name, not a path. */
3297
+ readonly name: string;
3298
+ /** Whether the entry itself is a directory. `false` for a symbolic link, even one targeting a directory. */
3299
+ readonly isDirectory: boolean;
3300
+ /** Whether the entry itself is a symbolic link. */
3301
+ readonly isSymbolicLink: boolean;
3230
3302
  }
3231
3303
  /**
3232
3304
  * The synchronous path operations the sync entry points need, supplied by the
@@ -3382,5 +3454,5 @@ interface GetWorkspacePackagesSyncOptions extends WorkspacesSyncOptions {
3382
3454
  */
3383
3455
  declare const getWorkspacePackagesSync: (root: string, options: GetWorkspacePackagesSyncOptions) => ReadonlyArray<WorkspacePackage>;
3384
3456
  //#endregion
3385
- export { type CatalogAssemblyFailure, CatalogSet, ChangeDetectionError, type ChangeDetectionFailure, ChangeDetectionOptions, ChangeDetector, type ChangeDetectorShape, type ClassifyOptions, ConfigDependencyHooks, type ConfigDependencyHooksShape, CyclicDependencyError, type DependencyDiff, DependencyGraph, DetectedPackageManager, type FindWorkspaceRootOptions, type GetWorkspacePackagesSyncOptions, type HookInjection, type ImporterVersions, LockfileReadError, type LockfileReadFailure, LockfileReader, type LockfileReaderOptions, type LockfileReaderShape, NoPeerDependencyRules, PackageManagerDetectionError, type PackageManagerDetectionFailure, PackageManagerDetector, type PackageManagerDetectorShape, PackageManagerEvidence, PackageManagerName, PackageNotFoundError, type PackageRelease, PackageStateSnapshot, PeerCheck, type PeerCheckOptions, type PeerDependencyRules, PeerParent, PublishConfig, PublishTarget, PublishabilityDetector, type PublishabilityDetectorShape, ReleaseTag, type SyncFileSystem, type SyncPath, type TagClassification, type TagFormatOptions, TagStyle, TrackingTag, type TrackingTagOptions, UnsatisfiedPeer, type UnverifiedReason, type VersioningDetectOptions, VersioningStrategy, VersioningStrategyType, WORKSPACE_MARKERS, WorkspaceCatalogs, type WorkspaceCatalogsOptions, type WorkspaceCatalogsShape, WorkspaceDiscovery, WorkspaceDiscoveryError, type WorkspaceDiscoveryFailure, type WorkspaceDiscoveryOptions, type WorkspaceDiscoveryShape, WorkspaceInfo, type WorkspaceLookupFailure, WorkspaceManifestError, WorkspacePackage, WorkspacePatternError, WorkspaceRoot, WorkspaceRootNotFoundError, type WorkspaceRootShape, type WorkspaceSnapshotAtFailure, type WorkspaceSnapshotWorktreeFailure, WorkspaceSnapshots, type WorkspaceSnapshotsOptions, type WorkspaceSnapshotsShape, WorkspaceStateSnapshot, Workspaces, type WorkspacesOptions, type WorkspacesServices, type WorkspacesSyncOptions, classifyTag, findWorkspaceRootSync, getWorkspacePackagesSync };
3457
+ export { type CatalogAssemblyFailure, CatalogSet, ChangeDetectionError, type ChangeDetectionFailure, ChangeDetectionOptions, ChangeDetector, type ChangeDetectorShape, type ClassifyOptions, ConfigDependencyHooks, type ConfigDependencyHooksShape, CyclicDependencyError, type DependencyDiff, DependencyGraph, DetectedPackageManager, type FindWorkspaceRootOptions, type GetWorkspacePackagesSyncOptions, type HookInjection, type ImporterVersions, LockfileReadError, type LockfileReadFailure, LockfileReader, type LockfileReaderOptions, type LockfileReaderShape, NoPeerDependencyRules, PackageManagerDetectionError, type PackageManagerDetectionFailure, PackageManagerDetector, type PackageManagerDetectorShape, PackageManagerEvidence, PackageManagerName, PackageNotFoundError, type PackageRelease, PackageStateSnapshot, PeerCheck, type PeerCheckOptions, type PeerDependencyRules, PeerParent, PublishConfig, PublishTarget, PublishabilityDetector, type PublishabilityDetectorShape, ReleaseTag, type SyncDirectoryEntry, type SyncFileSystem, type SyncPath, type TagClassification, type TagFormatOptions, TagStyle, TrackingTag, type TrackingTagOptions, UnsatisfiedPeer, type UnverifiedReason, type VersioningDetectOptions, VersioningStrategy, VersioningStrategyType, WORKSPACE_MARKERS, WorkspaceCatalogs, type WorkspaceCatalogsOptions, type WorkspaceCatalogsShape, WorkspaceDiscovery, WorkspaceDiscoveryError, type WorkspaceDiscoveryFailure, type WorkspaceDiscoveryOptions, type WorkspaceDiscoveryShape, WorkspaceInfo, type WorkspaceLookupFailure, WorkspaceManifestError, WorkspacePackage, WorkspacePatternError, WorkspaceRoot, WorkspaceRootNotFoundError, type WorkspaceRootShape, type WorkspaceSnapshotAtFailure, type WorkspaceSnapshotWorktreeFailure, WorkspaceSnapshots, type WorkspaceSnapshotsOptions, type WorkspaceSnapshotsShape, WorkspaceStateSnapshot, Workspaces, type WorkspacesOptions, type WorkspacesServices, type WorkspacesSyncOptions, classifyTag, findWorkspaceRootSync, getWorkspacePackagesSync };
3386
3458
  //# sourceMappingURL=index.d.ts.map
package/node-sync.d.ts CHANGED
@@ -36,6 +36,53 @@ interface SyncFileSystem {
36
36
  readonly readDirectory: (path: string) => ReadonlyArray<string>;
37
37
  /** Whether `path` is a directory. May throw; a throw reads as `false`. */
38
38
  readonly isDirectory: (path: string) => boolean;
39
+ /**
40
+ * Optional fast path: the entries inside `path` with their types already
41
+ * known, in ONE call.
42
+ *
43
+ * @remarks
44
+ * Package enumeration otherwise costs a `readDirectory` plus one
45
+ * `isDirectory` per entry — the readdir-then-stat-per-entry shape, which on
46
+ * a large workspace is a syscall per file. Supplying this collapses that to
47
+ * a single `readdirSync(path, { withFileTypes: true })`; `nodeFileSystem`
48
+ * does. Omit it and enumeration falls back to the four required operations
49
+ * with identical results, so this is purely a cost optimization and never a
50
+ * behavior switch.
51
+ *
52
+ * May throw; a throw skips the directory, exactly like `readDirectory`.
53
+ */
54
+ readonly readDirectoryWithTypes?: ((path: string) => ReadonlyArray<SyncDirectoryEntry>) | undefined;
55
+ }
56
+ /**
57
+ * One directory entry with its type resolved, as the optional
58
+ * {@link SyncFileSystem.readDirectoryWithTypes} fast path reports it. Node's
59
+ * `Dirent` satisfies it after mapping its predicate methods to booleans.
60
+ *
61
+ * @remarks
62
+ * `isSymbolicLink` is not decoration. A `Dirent` describes the entry ITSELF, so
63
+ * a symbolic link pointing at a directory reports `isDirectory: false` — while
64
+ * the `stat`-based slow path, which resolves the link, calls the same entry a
65
+ * directory. Enumeration therefore re-resolves links through `isDirectory`
66
+ * rather than trusting `isDirectory` on a link, which is what keeps the fast
67
+ * and slow paths in agreement on a workspace whose packages are symlinked.
68
+ *
69
+ * Which behavior is *correct* is domain-dependent, so the flag is reported
70
+ * rather than resolved away. Enumeration follows links because a symlinked
71
+ * package is still a package. A test-file discovery walk usually must NOT: in a
72
+ * pnpm workspace `node_modules` is a farm of links into the content-addressed
73
+ * store, and following them walks the whole store or hits a cycle. A consumer
74
+ * building its own walker on this shape wants `isDirectory` verbatim — there,
75
+ * not following is the requirement, not the hazard.
76
+ *
77
+ * @public
78
+ */
79
+ interface SyncDirectoryEntry {
80
+ /** The entry's own name, not a path. */
81
+ readonly name: string;
82
+ /** Whether the entry itself is a directory. `false` for a symbolic link, even one targeting a directory. */
83
+ readonly isDirectory: boolean;
84
+ /** Whether the entry itself is a symbolic link. */
85
+ readonly isSymbolicLink: boolean;
39
86
  }
40
87
  /**
41
88
  * The synchronous path operations the sync entry points need, supplied by the
@@ -114,5 +161,5 @@ declare const nodePath: SyncPath;
114
161
  */
115
162
  declare const nodeSyncOps: WorkspacesSyncOptions;
116
163
  //#endregion
117
- export { type SyncFileSystem, type SyncPath, type WorkspacesSyncOptions, nodeFileSystem, nodePath, nodeSyncOps };
164
+ export { type SyncDirectoryEntry, type SyncFileSystem, type SyncPath, type WorkspacesSyncOptions, nodeFileSystem, nodePath, nodeSyncOps };
118
165
  //# sourceMappingURL=node-sync.d.ts.map
package/node-sync.js CHANGED
@@ -41,7 +41,12 @@ const nodeFileSystem = {
41
41
  exists: existsSync,
42
42
  readFile: (p) => readFileSync(p, "utf8"),
43
43
  readDirectory: (p) => readdirSync(p),
44
- isDirectory: (p) => statSync(p).isDirectory()
44
+ isDirectory: (p) => statSync(p).isDirectory(),
45
+ readDirectoryWithTypes: (p) => readdirSync(p, { withFileTypes: true }).map((entry) => ({
46
+ name: entry.name,
47
+ isDirectory: entry.isDirectory(),
48
+ isSymbolicLink: entry.isSymbolicLink()
49
+ }))
45
50
  };
46
51
  /**
47
52
  * `SyncPath` as the running platform's `node:path` — win32 semantics on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/workspaces",
3
- "version": "0.15.1",
3
+ "version": "0.17.0",
4
4
  "private": false,
5
5
  "description": "Monorepo workspace tooling as Effect services — root discovery, package enumeration, the dependency graph, package-manager detection, pnpm catalog resolution, lockfile IO and git-based change detection.",
6
6
  "keywords": [
@@ -49,8 +49,8 @@
49
49
  "@effected/commands": "^0.5.0",
50
50
  "@effected/git": "^0.9.0",
51
51
  "@effected/glob": "^0.4.0",
52
- "@effected/lockfiles": "^0.6.1",
53
- "@effected/npm": "^0.11.0",
52
+ "@effected/lockfiles": "^0.6.2",
53
+ "@effected/npm": "^0.11.1",
54
54
  "@effected/package-json": "^0.10.2",
55
55
  "@effected/semver": "^0.5.0",
56
56
  "@effected/walker": "^0.5.0",
@@ -5,7 +5,7 @@
5
5
  "toolPackages": [
6
6
  {
7
7
  "packageName": "@microsoft/api-extractor",
8
- "packageVersion": "7.58.12"
8
+ "packageVersion": "7.58.13"
9
9
  }
10
10
  ]
11
11
  }