@effected/workspaces 0.1.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.
@@ -0,0 +1,165 @@
1
+ import { CatalogSet } from "./WorkspaceCatalogs.js";
2
+ import { Effect, Exit, Layer, Option, Schema } from "effect";
3
+ import { CatalogResolver, DependencySpecifier, WorkspaceResolver } from "@effected/npm";
4
+
5
+ //#region src/WorkspaceStateSnapshot.ts
6
+ const EMPTY = Object.freeze(Object.create(null));
7
+ const DependencyMap = Schema.Record(Schema.String, Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed(EMPTY)), Schema.withConstructorDefault(Effect.succeed(EMPTY)));
8
+ /**
9
+ * One workspace member as captured in a {@link WorkspaceStateSnapshot} — the
10
+ * serializable slice a snapshot diff reads: identity, version, location, and the
11
+ * four dependency records.
12
+ *
13
+ * @remarks
14
+ * Deliberately narrower than {@link WorkspacePackage}: a snapshot is a value to
15
+ * store and diff, not a located member to act on, so it carries no absolute
16
+ * paths, `publishConfig`, or `private` flag. The four records are keyed by the
17
+ * standard manifest field names, which are exactly `@effected/npm`'s
18
+ * `DependencyField` values.
19
+ *
20
+ * @public
21
+ */
22
+ var PackageStateSnapshot = class extends Schema.Class("PackageStateSnapshot")({
23
+ /** The package name. */
24
+ name: Schema.NonEmptyString,
25
+ /** The raw `version` string, as recorded at the captured moment. */
26
+ version: Schema.String,
27
+ /** POSIX path relative to the workspace root; `"."` for the root package. */
28
+ relativePath: Schema.String,
29
+ /** Production dependencies. */
30
+ dependencies: DependencyMap,
31
+ /** Development dependencies. */
32
+ devDependencies: DependencyMap,
33
+ /** Peer dependencies. */
34
+ peerDependencies: DependencyMap,
35
+ /** Optional dependencies. */
36
+ optionalDependencies: DependencyMap
37
+ }) {
38
+ /**
39
+ * Every dependency, merged across the four kinds.
40
+ *
41
+ * @remarks
42
+ * Precedence on a name declared in several kinds runs
43
+ * `dependencies` \> `devDependencies` \> `peerDependencies` \>
44
+ * `optionalDependencies`.
45
+ */
46
+ get allDependencies() {
47
+ return {
48
+ ...this.optionalDependencies,
49
+ ...this.peerDependencies,
50
+ ...this.devDependencies,
51
+ ...this.dependencies
52
+ };
53
+ }
54
+ };
55
+ /**
56
+ * The state of a whole workspace at one moment — its packages and its assembled
57
+ * catalog set — as a serializable value.
58
+ *
59
+ * @remarks
60
+ * Produced by `WorkspaceSnapshots.at` (a git ref, read with no checkout)
61
+ * or `WorkspaceSnapshots.worktree` (the live tree). The lookup and
62
+ * resolution surfaces (`versions`, `package`, `resolve`, the resolver layers)
63
+ * are backed by `#private` indexes built lazily on first use and never encoded —
64
+ * the `DependencyGraph` edge-index precedent.
65
+ *
66
+ * `resolve` and the resolver layers answer specifiers against THIS snapshot's
67
+ * own captured state, so a consumer can ask "what did `catalog:` /
68
+ * `workspace:*` mean as of that ref". An unmatched specifier is always
69
+ * `Option.none()`, never an error.
70
+ *
71
+ * @example
72
+ * ```ts
73
+ * import { WorkspaceSnapshots } from "@effected/workspaces";
74
+ * import { Effect } from "effect";
75
+ *
76
+ * const program = Effect.gen(function* () {
77
+ * const snapshots = yield* WorkspaceSnapshots;
78
+ * const before = yield* snapshots.at("origin/main");
79
+ * return before.resolve("effect", "catalog:");
80
+ * });
81
+ * ```
82
+ *
83
+ * @public
84
+ */
85
+ var WorkspaceStateSnapshot = class extends Schema.Class("WorkspaceStateSnapshot")({
86
+ /** Every workspace package captured at this moment. */
87
+ packages: Schema.Array(PackageStateSnapshot),
88
+ /** The catalog set assembled at this moment. */
89
+ catalogs: CatalogSet
90
+ }) {
91
+ #versionIndex;
92
+ #packageIndex;
93
+ #catalogResolver;
94
+ #workspaceResolver;
95
+ #resolvers;
96
+ #versions() {
97
+ if (this.#versionIndex === void 0) this.#versionIndex = new Map(this.packages.map((pkg) => [pkg.name, pkg.version]));
98
+ return this.#versionIndex;
99
+ }
100
+ #packages() {
101
+ if (this.#packageIndex === void 0) this.#packageIndex = new Map(this.packages.map((pkg) => [pkg.name, pkg]));
102
+ return this.#packageIndex;
103
+ }
104
+ /** Every captured package's name → version. Total; O(1) after the first call. */
105
+ get versions() {
106
+ return this.#versions();
107
+ }
108
+ /** A single captured package by name, or `Option.none()`. Total. */
109
+ package(name) {
110
+ return Option.fromUndefinedOr(this.#packages().get(name));
111
+ }
112
+ /**
113
+ * The concrete range or version a specifier resolved to AS OF this snapshot.
114
+ *
115
+ * @remarks
116
+ * The specifier is classified through `@effected/npm`'s
117
+ * `DependencySpecifier.FromString` — never by prefix-sniffing.
118
+ * A `workspace:` specifier resolves to the captured version of `dependency`; a
119
+ * `catalog:` specifier resolves against the captured catalog set. Every other
120
+ * form — a plain range, a dist-tag, a `file:`/git/url specifier, or an
121
+ * unparseable string — is `Option.none()`, because there is no indirection to
122
+ * resolve. Total.
123
+ *
124
+ * @param dependency - The dependency's package name (what `workspace:` /
125
+ * `catalog:` resolve for).
126
+ * @param specifier - The raw specifier string.
127
+ */
128
+ resolve(dependency, specifier) {
129
+ const exit = Schema.decodeUnknownExit(DependencySpecifier.FromString)(specifier);
130
+ if (!Exit.isSuccess(exit)) return Option.none();
131
+ const classified = exit.value;
132
+ switch (classified._tag) {
133
+ case "catalog": return this.catalogs.rangeOf(dependency, classified.name);
134
+ case "workspace": return Option.fromUndefinedOr(this.#versions().get(dependency));
135
+ default: return Option.none();
136
+ }
137
+ }
138
+ /**
139
+ * A `CatalogResolver` layer implementing `@effected/npm`'s contract against
140
+ * THIS snapshot's catalog set — so code written to the contract resolves
141
+ * `catalog:` specifiers as of this ref. Built once per instance and cached, so
142
+ * it memoizes by reference.
143
+ */
144
+ get catalogResolver() {
145
+ if (this.#catalogResolver === void 0) this.#catalogResolver = Layer.succeed(CatalogResolver, { rangeOf: (packageName, catalog) => Effect.succeed(this.catalogs.rangeOf(packageName, catalog)) });
146
+ return this.#catalogResolver;
147
+ }
148
+ /**
149
+ * A `WorkspaceResolver` layer implementing `@effected/npm`'s contract against
150
+ * THIS snapshot's captured versions — so code written to the contract resolves
151
+ * `workspace:` specifiers as of this ref. Built once per instance and cached.
152
+ */
153
+ get workspaceResolver() {
154
+ if (this.#workspaceResolver === void 0) this.#workspaceResolver = Layer.succeed(WorkspaceResolver, { versionOf: (packageName) => Effect.succeed(Option.fromUndefinedOr(this.#versions().get(packageName))) });
155
+ return this.#workspaceResolver;
156
+ }
157
+ /** Both snapshot-scoped resolver layers merged. Built once per instance and cached. */
158
+ get resolvers() {
159
+ if (this.#resolvers === void 0) this.#resolvers = Layer.mergeAll(this.catalogResolver, this.workspaceResolver);
160
+ return this.#resolvers;
161
+ }
162
+ };
163
+
164
+ //#endregion
165
+ export { PackageStateSnapshot, WorkspaceStateSnapshot };
package/Workspaces.js ADDED
@@ -0,0 +1,113 @@
1
+ import { WorkspaceRoot } from "./WorkspaceRoot.js";
2
+ import { WorkspaceDiscovery } from "./WorkspaceDiscovery.js";
3
+ import { ChangeDetector } from "./ChangeDetector.js";
4
+ import { PackageManagerDetector } from "./PackageManagerName.js";
5
+ import { LockfileReader } from "./LockfileReader.js";
6
+ import { PublishabilityDetector } from "./Publishability.js";
7
+ import { WorkspaceCatalogs } from "./WorkspaceCatalogs.js";
8
+ import { WorkspaceSnapshots } from "./WorkspaceSnapshots.js";
9
+ import { Layer } from "effect";
10
+ import { Git } from "@effected/git";
11
+
12
+ //#region src/Workspaces.ts
13
+ /**
14
+ * Every service that needs only a filesystem: root, package-manager detection,
15
+ * discovery, lockfile reading, catalogs and publishability.
16
+ *
17
+ * @remarks
18
+ * Requires core `FileSystem` and `Path`, which the consumer provides at the
19
+ * edge (`@effect/platform-node`, `@effect/platform-bun`, or a test's
20
+ * `FileSystem.layerNoop`).
21
+ *
22
+ * **Bind the result to a `const`.** This is a parameterized factory and layers
23
+ * memoize by reference, so calling it twice builds everything twice.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * import { Workspaces } from "@effected/workspaces";
28
+ * import { Layer } from "effect";
29
+ *
30
+ * const WorkspacesLayer = Workspaces.layer();
31
+ * const AppLayer = Layer.provide(WorkspacesLayer, PlatformLayer);
32
+ * ```
33
+ *
34
+ * @public
35
+ */
36
+ const compose = (options, catalogsFactory) => {
37
+ const roots = WorkspaceRoot.layer;
38
+ const detector = PackageManagerDetector.layer;
39
+ const discovery = WorkspaceDiscovery.layer(options).pipe(Layer.provide(roots));
40
+ const lockfiles = LockfileReader.layer(options).pipe(Layer.provide(roots), Layer.provide(detector), Layer.provide(discovery));
41
+ const catalogs = catalogsFactory(options).pipe(Layer.provide(roots), Layer.provide(lockfiles));
42
+ return Layer.mergeAll(roots, detector, discovery, lockfiles, catalogs, PublishabilityDetector.layer);
43
+ };
44
+ const layer = (options) => compose(options, WorkspaceCatalogs.layer);
45
+ /**
46
+ * The git-free composite plus {@link ChangeDetector} and
47
+ * {@link WorkspaceSnapshots}, over `@effected/git`'s `Git` service.
48
+ *
49
+ * @remarks
50
+ * The extra requirement is core's `ChildProcessSpawner` (behind `Git`), which
51
+ * is why it is a separate layer rather than a flag: a consumer that never
52
+ * detects changes or reads at a ref should not have to be able to spawn a
53
+ * subprocess. The consumer provides `ChildProcessSpawner` once at the edge
54
+ * (`@effect/platform-node`'s `NodeServices.layer`); a test provides
55
+ * `Layer.succeed(Git, …)` and needs no repository on disk.
56
+ *
57
+ * @public
58
+ */
59
+ const layerWithGit = (options) => {
60
+ const core = layer(options);
61
+ const git = Git.layer;
62
+ return Layer.mergeAll(core, git, ChangeDetector.layer.pipe(Layer.provide(git), Layer.provide(core)), WorkspaceSnapshots.layer(options).pipe(Layer.provide(git), Layer.provide(core)));
63
+ };
64
+ /**
65
+ * The two `@effected/npm` resolver contracts, implemented for real.
66
+ *
67
+ * @remarks
68
+ * Provide this alongside `@effected/package-json`'s `Package.resolve` and a
69
+ * manifest's `catalog:` and `workspace:` specifiers resolve against the actual
70
+ * workspace instead of the no-op layers' `Option.none()`.
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * import { Package } from "@effected/package-json";
75
+ * import { Workspaces } from "@effected/workspaces";
76
+ * import { Layer } from "effect";
77
+ *
78
+ * const WorkspacesLayer = Workspaces.layer();
79
+ * const Resolvers = Workspaces.resolvers.pipe(Layer.provide(WorkspacesLayer));
80
+ * ```
81
+ *
82
+ * @public
83
+ */
84
+ const resolvers = Layer.mergeAll(WorkspaceCatalogs.catalogResolver, WorkspaceDiscovery.workspaceResolver);
85
+ /**
86
+ * The git-free composite, but with catalog assembly that **replays config
87
+ * dependency `pnpmfile.cjs` hooks** — {@link WorkspaceCatalogs.layerWithConfigDependencies}
88
+ * in place of the default no-op catalogs layer.
89
+ *
90
+ * @remarks
91
+ * Identical requirement set to {@link Workspaces.layer}; the only difference is
92
+ * that config-dependency code is executed in process. Opt in deliberately — the
93
+ * default {@link Workspaces.layer} never executes config-dependency code.
94
+ *
95
+ * **Bind the result to a `const`.**
96
+ *
97
+ * @public
98
+ */
99
+ const layerWithConfigDependencies = (options) => compose(options, WorkspaceCatalogs.layerWithConfigDependencies);
100
+ /**
101
+ * The composite layers.
102
+ *
103
+ * @public
104
+ */
105
+ const Workspaces = {
106
+ layer,
107
+ layerWithConfigDependencies,
108
+ layerWithGit,
109
+ resolvers
110
+ };
111
+
112
+ //#endregion
113
+ export { Workspaces };
@@ -0,0 +1,205 @@
1
+ import { Traversal, badMaxDepthMessage, isPruned, isValidMaxDepth, joinRelative } from "./internal/traverse.js";
2
+ import { manifestPatternsOf, pnpmPatternsOf } from "./internal/patterns.js";
3
+ import { PublishConfig, WorkspacePackage } from "./WorkspacePackage.js";
4
+ import { Effect, Exit, Schema } from "effect";
5
+ import { GlobSet } from "@effected/glob";
6
+ import { Yaml } from "@effected/yaml";
7
+ import { dirname, join, resolve } from "node:path";
8
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
9
+
10
+ //#region src/WorkspacesSync.ts
11
+ /**
12
+ * Read and JSON-parse a file into a plain object, or `undefined`. Never throws.
13
+ *
14
+ * The non-object check is load-bearing, not defensive noise. `JSON.parse`
15
+ * returns `undefined` for *nothing* — a `package.json` whose entire content is
16
+ * `null`, `42` or `"x"` parses successfully to that value, so a caller guarding
17
+ * only on `undefined` sails straight into `raw.name` and throws a `TypeError`.
18
+ * These functions are documented as total and are reached from a Vitest config,
19
+ * which has nowhere to put a crash: malformed input must be *skipped*, never a
20
+ * defect.
21
+ */
22
+ const readJson = (file) => {
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(readFileSync(file, "utf8"));
26
+ } catch {
27
+ return;
28
+ }
29
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : void 0;
30
+ };
31
+ /** Whether `dir` is a directory. Never throws. */
32
+ const isDirectory = (dir) => {
33
+ try {
34
+ return statSync(dir).isDirectory();
35
+ } catch {
36
+ return false;
37
+ }
38
+ };
39
+ /** Whether `dir` holds a `package.json`. */
40
+ const isPackage = (dir) => existsSync(join(dir, "package.json"));
41
+ /**
42
+ * The nearest workspace root at or above `cwd`, or `null`.
43
+ *
44
+ * @remarks
45
+ * **Synchronous and Node-only.** The Effect surface is `WorkspaceRoot`; reach
46
+ * for this one only where you genuinely cannot run an Effect — a Vitest config
47
+ * being the motivating case.
48
+ *
49
+ * Markers match the async service exactly: a `pnpm-workspace.yaml`, or a
50
+ * `package.json` carrying a `workspaces` field.
51
+ *
52
+ * @param cwd - Where to start the ascent. Defaults to `process.cwd()`.
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * import { findWorkspaceRootSync } from "@effected/workspaces";
57
+ *
58
+ * const root = findWorkspaceRootSync();
59
+ * ```
60
+ *
61
+ * @public
62
+ */
63
+ const findWorkspaceRootSync = (cwd) => {
64
+ let current = resolve(cwd ?? process.cwd());
65
+ for (let depth = 0; depth < 32 * 8; depth++) {
66
+ if (existsSync(join(current, "pnpm-workspace.yaml"))) return current;
67
+ const manifest = readJson(join(current, "package.json"));
68
+ if (manifest?.workspaces !== void 0 && manifest.workspaces !== null) return current;
69
+ const parent = dirname(current);
70
+ if (parent === current) return null;
71
+ current = parent;
72
+ }
73
+ return null;
74
+ };
75
+ /** The workspace `packages:` patterns for `root`, matching `internal/patterns.ts`'s precedence. */
76
+ const readPatternsSync = (root) => {
77
+ const workspaceYaml = join(root, "pnpm-workspace.yaml");
78
+ if (existsSync(workspaceYaml)) {
79
+ let text = "";
80
+ try {
81
+ text = readFileSync(workspaceYaml, "utf8");
82
+ } catch {
83
+ text = "";
84
+ }
85
+ const exit = Effect.runSyncExit(Yaml.parse(text));
86
+ if (Exit.isSuccess(exit)) {
87
+ const patterns = pnpmPatternsOf(exit.value);
88
+ if (patterns.length > 0) return patterns;
89
+ }
90
+ }
91
+ return manifestPatternsOf(readJson(join(root, "package.json")) ?? {});
92
+ };
93
+ /** Build a `WorkspacePackage` from a directory, or `null` if its manifest is unusable. */
94
+ const readPackageSync = (directory, relativePath) => {
95
+ const packageJsonPath = join(directory, "package.json");
96
+ const raw = readJson(packageJsonPath);
97
+ if (raw === void 0) return null;
98
+ const name = raw.name;
99
+ const version = raw.version;
100
+ if (typeof name !== "string" || name.length === 0) return null;
101
+ if (typeof version !== "string" || version.length === 0) return null;
102
+ const stringRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === "string") ? value : void 0;
103
+ const publishConfig = raw.publishConfig;
104
+ const config = publishConfig !== null && typeof publishConfig === "object" ? Effect.runSyncExit(Schema.decodeUnknownEffect(PublishConfig)(publishConfig)) : void 0;
105
+ return WorkspacePackage.make({
106
+ name,
107
+ version,
108
+ path: directory,
109
+ packageJsonPath,
110
+ relativePath,
111
+ private: raw.private === true,
112
+ dependencies: stringRecord(raw.dependencies) ?? {},
113
+ devDependencies: stringRecord(raw.devDependencies) ?? {},
114
+ peerDependencies: stringRecord(raw.peerDependencies) ?? {},
115
+ optionalDependencies: stringRecord(raw.optionalDependencies) ?? {},
116
+ ...config !== void 0 && Exit.isSuccess(config) ? { publishConfig: config.value } : {}
117
+ });
118
+ };
119
+ /**
120
+ * Every workspace package under `root`, root package first.
121
+ *
122
+ * @remarks
123
+ * **Synchronous and Node-only.** The Effect surface is `WorkspaceDiscovery`.
124
+ *
125
+ * Pattern semantics are not merely "shared" with the Effect enumerator — both
126
+ * drive the **same traversal state machine** (`internal/traverse.ts`), so the
127
+ * dequeue order, the depth rule, the visit budget and the prune list cannot
128
+ * drift apart. A `packages/**` finds exactly the same packages here as there,
129
+ * including at the depth boundary.
130
+ *
131
+ * The one deliberate difference is what happens at a bound: the Effect surface
132
+ * fails typed (`depthExceeded` / `budgetExceeded`), while this one is **total**
133
+ * and truncates. An unenumerable pattern or an unreadable manifest is skipped,
134
+ * not raised — a function with no error channel should not pretend to have one,
135
+ * and a Vitest config has nowhere to put a failure. Totality covers *data*, not
136
+ * caller mistakes: a `maxDepth` that is not a positive integer throws, matching
137
+ * the enumerator's defect.
138
+ *
139
+ * @param root - The workspace root, from {@link findWorkspaceRootSync}.
140
+ * @param options - Traversal bounds; see {@link GetWorkspacePackagesSyncOptions}.
141
+ *
142
+ * @example
143
+ * ```ts
144
+ * import { findWorkspaceRootSync, getWorkspacePackagesSync } from "@effected/workspaces";
145
+ *
146
+ * const root = findWorkspaceRootSync();
147
+ * const packages = root === null ? [] : getWorkspacePackagesSync(root);
148
+ * ```
149
+ *
150
+ * @public
151
+ */
152
+ const getWorkspacePackagesSync = (root, options) => {
153
+ const maxDepth = options?.maxDepth ?? 32;
154
+ if (!isValidMaxDepth(maxDepth)) throw new RangeError(`getWorkspacePackagesSync: ${badMaxDepthMessage(maxDepth)}`);
155
+ const patterns = readPatternsSync(root);
156
+ const compiled = Effect.runSyncExit(GlobSet.compile(patterns));
157
+ if (Exit.isFailure(compiled)) return [];
158
+ const globs = compiled.value;
159
+ const included = /* @__PURE__ */ new Map();
160
+ for (const literal of globs.literals) {
161
+ const absolute = join(root, literal);
162
+ if (isPackage(absolute)) included.set(literal, absolute);
163
+ }
164
+ for (const wildcard of globs.wildcards) {
165
+ const base = wildcard.enumerationPrefix.replace(/\/$/, "");
166
+ const absoluteBase = join(root, base);
167
+ if (!isDirectory(absoluteBase)) continue;
168
+ const traversal = new Traversal(base, absoluteBase, maxDepth);
169
+ let stopped = false;
170
+ for (let current = traversal.next(); current !== void 0 && !stopped; current = traversal.next()) {
171
+ if (traversal.charge() !== void 0) break;
172
+ let entries = [];
173
+ try {
174
+ entries = readdirSync(current.absolute);
175
+ } catch {
176
+ continue;
177
+ }
178
+ for (const entry of entries) {
179
+ if (isPruned(entry)) continue;
180
+ const relative = joinRelative(current.relative, entry);
181
+ const absolute = join(current.absolute, entry);
182
+ if (!isDirectory(absolute)) continue;
183
+ if (wildcard.crossesSegments && !traversal.admits(current)) {
184
+ stopped = true;
185
+ break;
186
+ }
187
+ if (wildcard.matches(relative) && isPackage(absolute)) included.set(relative, absolute);
188
+ if (!wildcard.crossesSegments) continue;
189
+ traversal.push(current, relative, absolute);
190
+ }
191
+ }
192
+ }
193
+ const members = [];
194
+ for (const [relativePath, absolute] of [...included.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
195
+ if (relativePath === "." || absolute === root) continue;
196
+ if (globs.excludes.some((exclude) => exclude.matches(relativePath))) continue;
197
+ const pkg = readPackageSync(absolute, relativePath);
198
+ if (pkg !== null) members.push(pkg);
199
+ }
200
+ const rootPackage = readPackageSync(root, ".");
201
+ return rootPackage === null ? members : [rootPackage, ...members];
202
+ };
203
+
204
+ //#endregion
205
+ export { findWorkspaceRootSync, getWorkspacePackagesSync };