@effected/workspaces 0.6.2 → 0.7.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.
@@ -2,6 +2,7 @@ import { WorkspaceRoot } from "./WorkspaceRoot.js";
2
2
  import { inlineCatalogs, merge, normalize, rangeOf } from "./internal/catalogs.js";
3
3
  import { ConfigDependencyHooks } from "./ConfigDependencyHooks.js";
4
4
  import { LockfileReader } from "./LockfileReader.js";
5
+ import { importerVersionsOf } from "./internal/importerVersions.js";
5
6
  import { Context, Duration, Effect, Exit, FileSystem, Layer, Option, Path, PlatformError, Schema } from "effect";
6
7
  import { CatalogAssemblyError, CatalogResolver, DependencyResolutionError, PartialReleaseAgeGate, ReleaseAgeGate } from "@effected/npm";
7
8
  import { Yaml } from "@effected/yaml";
@@ -325,7 +326,15 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
325
326
  }));
326
327
  const assemble = Effect.gen(function* () {
327
328
  const root = yield* Effect.suspend(() => roots.find(options?.cwd ?? process.cwd()));
328
- const fromLockfile = yield* lockfiles.read().pipe(Effect.map(CatalogSet.fromLockfile), Effect.catch((_failure) => Effect.succeed(CatalogSet.empty())));
329
+ const lockfileOutputs = yield* lockfiles.read().pipe(Effect.map((lockfile) => ({
330
+ catalogs: CatalogSet.fromLockfile(lockfile),
331
+ importerVersions: importerVersionsOf(lockfile)
332
+ })), Effect.catch((_failure) => Effect.succeed({
333
+ catalogs: CatalogSet.empty(),
334
+ importerVersions: {}
335
+ })));
336
+ const fromLockfile = lockfileOutputs.catalogs;
337
+ const importerVersions = lockfileOutputs.importerVersions;
329
338
  const workspaceYaml = path.join(root, "pnpm-workspace.yaml");
330
339
  const hasPnpmWorkspace = yield* probeExists(workspaceYaml);
331
340
  let inline;
@@ -353,7 +362,8 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
353
362
  const manifestPath = path.join(root, "package.json");
354
363
  if (!(yield* probeExists(manifestPath))) return {
355
364
  catalogs: fromLockfile,
356
- releaseAgeGate: ReleaseAgeGate.combine()
365
+ releaseAgeGate: ReleaseAgeGate.combine(),
366
+ importerVersions
357
367
  };
358
368
  const text = yield* fs.readFileString(manifestPath).pipe(Effect.mapError((cause) => new CatalogAssemblyError({
359
369
  source: "manifest",
@@ -372,7 +382,8 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
372
382
  }));
373
383
  return {
374
384
  catalogs: assembled,
375
- releaseAgeGate
385
+ releaseAgeGate,
386
+ importerVersions
376
387
  };
377
388
  });
378
389
  const [resolveOnce, invalidate] = yield* Effect.cachedInvalidateWithTTL(assemble, Duration.infinity);
@@ -386,6 +397,9 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
386
397
  }),
387
398
  releaseAgeGate: Effect.fn("WorkspaceCatalogs.releaseAgeGate")(function* () {
388
399
  return (yield* memo).releaseAgeGate;
400
+ }),
401
+ importerVersions: Effect.fn("WorkspaceCatalogs.importerVersions")(function* () {
402
+ return (yield* memo).importerVersions;
389
403
  })
390
404
  };
391
405
  });
Binary file
@@ -1,3 +1,4 @@
1
+ import { unanimousVersionOf } from "./internal/importerVersions.js";
1
2
  import { CatalogSet } from "./WorkspaceCatalogs.js";
2
3
  import { Effect, Exit, Layer, Option, Schema } from "effect";
3
4
  import { CatalogResolver, DependencySpecifier, WorkspaceResolver } from "@effected/npm";
@@ -86,7 +87,19 @@ var WorkspaceStateSnapshot = class extends Schema.Class("WorkspaceStateSnapshot"
86
87
  /** Every workspace package captured at this moment. */
87
88
  packages: Schema.Array(PackageStateSnapshot),
88
89
  /** The catalog set assembled at this moment. */
89
- catalogs: CatalogSet
90
+ catalogs: CatalogSet,
91
+ /**
92
+ * Each importer's dependency-name → resolved-version map, as the manager's
93
+ * lockfile recorded it at this moment.
94
+ *
95
+ * @remarks
96
+ * Defaults to `{}`, so a `WorkspaceStateSnapshot` serialized before this field
97
+ * existed still decodes — and an empty index simply makes the `catalog:`
98
+ * fallback in {@link WorkspaceStateSnapshot.resolve} inert, which is exactly
99
+ * the behavior those older values were captured under. Only pnpm records
100
+ * importer versions; bun and npm yield an empty index.
101
+ */
102
+ importerVersions: Schema.optionalKey(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.String)))
90
103
  }) {
91
104
  #versionIndex;
92
105
  #packageIndex;
@@ -121,16 +134,62 @@ var WorkspaceStateSnapshot = class extends Schema.Class("WorkspaceStateSnapshot"
121
134
  * unparseable string — is `Option.none()`, because there is no indirection to
122
135
  * resolve. Total.
123
136
  *
137
+ * A `catalog:` specifier the catalog set cannot resolve falls back to this
138
+ * snapshot's `importerVersions` — but only to a version
139
+ * **every** importer recording that dependency agrees on. A catalog injected
140
+ * by a config-dependency pnpmfile hook appears in no committed catalog source,
141
+ * so without this fallback both sides of a before/after diff resolve it to the
142
+ * same raw string and a real version movement produces no row. When importers
143
+ * disagree there is no single correct answer, so this stays `Option.none()`
144
+ * rather than inventing one; {@link WorkspaceStateSnapshot.resolveIn} answers
145
+ * precisely for callers that know which importer is asking.
146
+ *
124
147
  * @param dependency - The dependency's package name (what `workspace:` /
125
148
  * `catalog:` resolve for).
126
149
  * @param specifier - The raw specifier string.
127
150
  */
128
151
  resolve(dependency, specifier) {
152
+ return this.#resolveWith(dependency, specifier, () => Option.fromUndefinedOr(unanimousVersionOf(this.importerVersions ?? {}, dependency)));
153
+ }
154
+ /**
155
+ * The concrete range or version a specifier resolved to AS OF this snapshot,
156
+ * scoped to the importer that declared it.
157
+ *
158
+ * @remarks
159
+ * Identical to {@link WorkspaceStateSnapshot.resolve} except in how an
160
+ * unresolvable `catalog:` specifier falls back: this consults **only**
161
+ * `importerPath`'s own recorded versions, so a monorepo whose packages hold
162
+ * different versions of one dependency still gets an exact answer where
163
+ * `resolve` must abstain. Prefer this whenever the caller knows the importer —
164
+ * a consumer iterating `packages` has `relativePath` in hand, which is the
165
+ * importer key (`"."` for the root package).
166
+ *
167
+ * An unknown `importerPath`, or one recording nothing for `dependency`, is
168
+ * `Option.none()`. Total.
169
+ *
170
+ * @param importerPath - The importer's path relative to the workspace root,
171
+ * `"."` for the root package — `PackageStateSnapshot.relativePath`.
172
+ * @param dependency - The dependency's package name.
173
+ * @param specifier - The raw specifier string.
174
+ */
175
+ resolveIn(importerPath, dependency, specifier) {
176
+ return this.#resolveWith(dependency, specifier, () => Option.fromUndefinedOr(this.importerVersions?.[importerPath]?.[dependency]));
177
+ }
178
+ /**
179
+ * The shared resolution path: classify, answer from the captured state, and
180
+ * consult `onUnresolvedCatalog` only for a `catalog:` specifier the catalog set
181
+ * could not answer. A plain range is already its own answer and must keep
182
+ * resolving to `Option.none()` so the caller falls back to the raw string.
183
+ */
184
+ #resolveWith(dependency, specifier, onUnresolvedCatalog) {
129
185
  const exit = Schema.decodeUnknownExit(DependencySpecifier.FromString)(specifier);
130
186
  if (!Exit.isSuccess(exit)) return Option.none();
131
187
  const classified = exit.value;
132
188
  switch (classified._tag) {
133
- case "catalog": return this.catalogs.rangeOf(dependency, classified.name);
189
+ case "catalog": {
190
+ const fromCatalogs = this.catalogs.rangeOf(dependency, classified.name);
191
+ return Option.isSome(fromCatalogs) ? fromCatalogs : onUnresolvedCatalog();
192
+ }
134
193
  case "workspace": return Option.fromUndefinedOr(this.#versions().get(dependency));
135
194
  default: return Option.none();
136
195
  }
package/index.d.ts CHANGED
@@ -1323,6 +1323,26 @@ declare class PublishabilityDetector extends PublishabilityDetector_base {
1323
1323
  }
1324
1324
  //#endregion
1325
1325
  //#region src/WorkspaceCatalogs.d.ts
1326
+ /**
1327
+ * Each importer's dependency-name → resolved-version map, keyed by importer path
1328
+ * (`"."` for the root package — the same keys `WorkspaceDiscovery.importerMap()`
1329
+ * uses, and the same value `PackageStateSnapshot.relativePath` carries).
1330
+ *
1331
+ * @remarks
1332
+ * What a manager's lockfile records as actually installed, as opposed to what a
1333
+ * manifest declares. It answers a `catalog:` specifier no committed catalog
1334
+ * source declares — the shape a pnpm config-dependency pnpmfile hook produces —
1335
+ * which would otherwise resolve to nothing on both sides of a diff and hide a
1336
+ * real version movement.
1337
+ *
1338
+ * Versions are normalized: pnpm's peer-disambiguation suffix
1339
+ * (`1.2.3(effect@4.0.0)`) is stripped, and `link:` / `file:` entries are omitted
1340
+ * because a filesystem edge is not a version. Only pnpm records importer
1341
+ * versions; bun and npm yield an empty index.
1342
+ *
1343
+ * @public
1344
+ */
1345
+ type ImporterVersions = Readonly<Record<string, Readonly<Record<string, string>>>>;
1326
1346
  declare const CatalogSet_base: Schema.Class<CatalogSet, Schema.Struct<{
1327
1347
  /** Catalog name → dependency name → version range. */
1328
1348
  readonly entries: Schema.$Record<Schema.String, Schema.$Record<Schema.String, Schema.String>>;
@@ -1455,6 +1475,19 @@ interface WorkspaceCatalogsShape {
1455
1475
  * workspace) has no release-age keys, so the gate is the inert zero gate.
1456
1476
  */
1457
1477
  readonly releaseAgeGate: () => Effect.Effect<ReleaseAgeGate, CatalogAssemblyFailure>;
1478
+ /**
1479
+ * Each importer's dependency-name → resolved-version map, as the manager's
1480
+ * lockfile records it. Read from the same single lockfile read as `set`, and
1481
+ * memoized with it.
1482
+ *
1483
+ * @remarks
1484
+ * Feeds `WorkspaceStateSnapshot`'s fallback for a `catalog:` specifier no
1485
+ * committed catalog source declares — the shape a config-dependency pnpmfile
1486
+ * hook produces. Only pnpm records importer versions; a bun or npm workspace
1487
+ * yields an empty index, and an absent or unreadable lockfile contributes
1488
+ * nothing rather than failing.
1489
+ */
1490
+ readonly importerVersions: () => Effect.Effect<ImporterVersions, CatalogAssemblyFailure>;
1458
1491
  }
1459
1492
  /**
1460
1493
  * Options for the {@link WorkspaceCatalogs} layer.
@@ -1583,6 +1616,18 @@ declare const WorkspaceStateSnapshot_base: Schema.Class<WorkspaceStateSnapshot,
1583
1616
  readonly packages: Schema.$Array<typeof PackageStateSnapshot>;
1584
1617
  /** The catalog set assembled at this moment. */
1585
1618
  readonly catalogs: typeof CatalogSet;
1619
+ /**
1620
+ * Each importer's dependency-name → resolved-version map, as the manager's
1621
+ * lockfile recorded it at this moment.
1622
+ *
1623
+ * @remarks
1624
+ * Defaults to `{}`, so a `WorkspaceStateSnapshot` serialized before this field
1625
+ * existed still decodes — and an empty index simply makes the `catalog:`
1626
+ * fallback in {@link WorkspaceStateSnapshot.resolve} inert, which is exactly
1627
+ * the behavior those older values were captured under. Only pnpm records
1628
+ * importer versions; bun and npm yield an empty index.
1629
+ */
1630
+ readonly importerVersions: Schema.optionalKey<Schema.$Record<Schema.String, Schema.$Record<Schema.String, Schema.String>>>;
1586
1631
  }>, {}>;
1587
1632
  /**
1588
1633
  * The state of a whole workspace at one moment — its packages and its assembled
@@ -1632,11 +1677,43 @@ declare class WorkspaceStateSnapshot extends WorkspaceStateSnapshot_base {
1632
1677
  * unparseable string — is `Option.none()`, because there is no indirection to
1633
1678
  * resolve. Total.
1634
1679
  *
1680
+ * A `catalog:` specifier the catalog set cannot resolve falls back to this
1681
+ * snapshot's `importerVersions` — but only to a version
1682
+ * **every** importer recording that dependency agrees on. A catalog injected
1683
+ * by a config-dependency pnpmfile hook appears in no committed catalog source,
1684
+ * so without this fallback both sides of a before/after diff resolve it to the
1685
+ * same raw string and a real version movement produces no row. When importers
1686
+ * disagree there is no single correct answer, so this stays `Option.none()`
1687
+ * rather than inventing one; {@link WorkspaceStateSnapshot.resolveIn} answers
1688
+ * precisely for callers that know which importer is asking.
1689
+ *
1635
1690
  * @param dependency - The dependency's package name (what `workspace:` /
1636
1691
  * `catalog:` resolve for).
1637
1692
  * @param specifier - The raw specifier string.
1638
1693
  */
1639
1694
  resolve(dependency: string, specifier: string): Option.Option<string>;
1695
+ /**
1696
+ * The concrete range or version a specifier resolved to AS OF this snapshot,
1697
+ * scoped to the importer that declared it.
1698
+ *
1699
+ * @remarks
1700
+ * Identical to {@link WorkspaceStateSnapshot.resolve} except in how an
1701
+ * unresolvable `catalog:` specifier falls back: this consults **only**
1702
+ * `importerPath`'s own recorded versions, so a monorepo whose packages hold
1703
+ * different versions of one dependency still gets an exact answer where
1704
+ * `resolve` must abstain. Prefer this whenever the caller knows the importer —
1705
+ * a consumer iterating `packages` has `relativePath` in hand, which is the
1706
+ * importer key (`"."` for the root package).
1707
+ *
1708
+ * An unknown `importerPath`, or one recording nothing for `dependency`, is
1709
+ * `Option.none()`. Total.
1710
+ *
1711
+ * @param importerPath - The importer's path relative to the workspace root,
1712
+ * `"."` for the root package — `PackageStateSnapshot.relativePath`.
1713
+ * @param dependency - The dependency's package name.
1714
+ * @param specifier - The raw specifier string.
1715
+ */
1716
+ resolveIn(importerPath: string, dependency: string, specifier: string): Option.Option<string>;
1640
1717
  /**
1641
1718
  * A `CatalogResolver` layer implementing `@effected/npm`'s contract against
1642
1719
  * THIS snapshot's catalog set — so code written to the contract resolves
@@ -1979,5 +2056,5 @@ interface GetWorkspacePackagesSyncOptions extends WorkspacesSyncOptions {
1979
2056
  */
1980
2057
  declare const getWorkspacePackagesSync: (root: string, options: GetWorkspacePackagesSyncOptions) => ReadonlyArray<WorkspacePackage>;
1981
2058
  //#endregion
1982
- export { type CatalogAssemblyFailure, CatalogSet, ChangeDetectionError, type ChangeDetectionFailure, ChangeDetectionOptions, ChangeDetector, type ChangeDetectorShape, ConfigDependencyHooks, type ConfigDependencyHooksShape, CyclicDependencyError, type DependencyDiff, DependencyGraph, DetectedPackageManager, type FindWorkspaceRootOptions, type GetWorkspacePackagesSyncOptions, type HookInjection, LockfileReadError, type LockfileReadFailure, LockfileReader, type LockfileReaderOptions, type LockfileReaderShape, PackageManagerDetectionError, type PackageManagerDetectionFailure, PackageManagerDetector, PackageManagerName, PackageNotFoundError, PackageStateSnapshot, PublishConfig, PublishTarget, PublishabilityDetector, type PublishabilityDetectorShape, type SyncFileSystem, type SyncPath, 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, findWorkspaceRootSync, getWorkspacePackagesSync };
2059
+ export { type CatalogAssemblyFailure, CatalogSet, ChangeDetectionError, type ChangeDetectionFailure, ChangeDetectionOptions, ChangeDetector, type ChangeDetectorShape, ConfigDependencyHooks, type ConfigDependencyHooksShape, CyclicDependencyError, type DependencyDiff, DependencyGraph, DetectedPackageManager, type FindWorkspaceRootOptions, type GetWorkspacePackagesSyncOptions, type HookInjection, type ImporterVersions, LockfileReadError, type LockfileReadFailure, LockfileReader, type LockfileReaderOptions, type LockfileReaderShape, PackageManagerDetectionError, type PackageManagerDetectionFailure, PackageManagerDetector, PackageManagerName, PackageNotFoundError, PackageStateSnapshot, PublishConfig, PublishTarget, PublishabilityDetector, type PublishabilityDetectorShape, type SyncFileSystem, type SyncPath, 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, findWorkspaceRootSync, getWorkspacePackagesSync };
1983
2060
  //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,88 @@
1
+ //#region src/internal/importerVersions.ts
2
+ /**
3
+ * Strip pnpm's peer-disambiguation suffix from a recorded importer version.
4
+ *
5
+ * pnpm records an importer version as the resolved version followed by the peer
6
+ * context it was resolved under — `4.0.0-beta.101(effect@4.0.0-beta.101)(ioredis@5.11.1(supports-color@8.1.1))`.
7
+ * `@effected/lockfiles` stores that string verbatim (its `packages:` key parser
8
+ * strips the suffix; the importer entries deliberately keep it), so the raw
9
+ * value must never reach a consumer's dependency table — it would render the
10
+ * whole parenthesized chain as the "version" a dependency moved to.
11
+ *
12
+ * Everything from the first `(` is dropped; a version with no suffix passes
13
+ * through untouched.
14
+ */
15
+ const stripPeerSuffix = (version) => {
16
+ const paren = version.indexOf("(");
17
+ return paren === -1 ? version : version.slice(0, paren);
18
+ };
19
+ /**
20
+ * Whether a recorded importer version is a concrete version usable as a
21
+ * resolution answer.
22
+ *
23
+ * A `link:` / `file:` entry records a filesystem edge rather than a version, and
24
+ * an empty string is pnpm's "specifier only, nothing installed" marker. Both
25
+ * would be worse than no answer at all: the fallback exists to report a concrete
26
+ * version movement, and reporting `link:../foo` as a version is noise a
27
+ * changeset would carry into release notes.
28
+ */
29
+ const isConcreteVersion = (version) => version.length > 0 && !version.startsWith("link:") && !version.startsWith("file:");
30
+ /**
31
+ * Build the importer-path → dependency-name → version index a snapshot uses to
32
+ * answer `catalog:` specifiers its catalog set cannot resolve.
33
+ *
34
+ * @remarks
35
+ * Keyed by **name only** within each importer, deliberately across every
36
+ * dependency field. The bug this fixes is a peer declared `catalog:effect:peers`
37
+ * whose importer records no `peerDependencies` entry at all — pnpm writes peer
38
+ * declarations into the importer block only when they are also installed, so the
39
+ * concrete version lives on the package's `devDependencies` row for the same
40
+ * name. Joining by name across fields is what finds it; joining by field, or by
41
+ * matching the recorded specifier, would not.
42
+ *
43
+ * Only pnpm populates importer versions; bun and npm record resolved versions on
44
+ * their package entries instead, so those formats yield an empty index and the
45
+ * fallback is inert for them.
46
+ */
47
+ const importerVersionsOf = (lockfile) => {
48
+ const index = Object.create(null);
49
+ for (const importer of lockfile.importers) {
50
+ const versions = Object.create(null);
51
+ for (const dependency of importer.dependencies) {
52
+ const recorded = dependency.version;
53
+ if (recorded === void 0) continue;
54
+ const version = stripPeerSuffix(recorded);
55
+ if (!isConcreteVersion(version)) continue;
56
+ versions[dependency.name] ??= version;
57
+ }
58
+ index[importer.path] = versions;
59
+ }
60
+ return index;
61
+ };
62
+ /**
63
+ * The version every importer agrees `dependency` resolved to, or `undefined`
64
+ * when they disagree or none record it.
65
+ *
66
+ * @remarks
67
+ * The unambiguity rule is what makes a workspace-wide answer safe from a
68
+ * snapshot method that receives no importer context. Two packages in one
69
+ * monorepo may legitimately hold different versions of the same dependency; in
70
+ * that case there is no single correct answer, so this yields nothing and the
71
+ * caller keeps today's behavior (no row) rather than inventing a wrong one.
72
+ */
73
+ const unanimousVersionOf = (index, dependency) => {
74
+ let agreed;
75
+ for (const versions of Object.values(index)) {
76
+ const version = versions[dependency];
77
+ if (version === void 0) continue;
78
+ if (agreed === void 0) {
79
+ agreed = version;
80
+ continue;
81
+ }
82
+ if (agreed !== version) return void 0;
83
+ }
84
+ return agreed;
85
+ };
86
+
87
+ //#endregion
88
+ export { importerVersionsOf, unanimousVersionOf };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/workspaces",
3
- "version": "0.6.2",
3
+ "version": "0.7.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": [