@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,43 @@
1
+ import { Schema } from "effect";
2
+
3
+ //#region src/CatalogAssemblyError.ts
4
+ /**
5
+ * Raised when a workspace's catalogs cannot be assembled — a `pnpm-workspace.yaml`
6
+ * that is unreadable or not valid YAML, a root `package.json` `workspaces` field
7
+ * whose shape or catalog blocks are malformed in a way pnpm itself rejects
8
+ * (including the default catalog declared twice), or a config dependency whose
9
+ * `pnpmfile.cjs` cannot be loaded or replayed.
10
+ *
11
+ * @remarks
12
+ * A *missing* `pnpm-workspace.yaml`, an *absent* `workspaces` field, or one
13
+ * explicitly `null` is not an error: there is simply nothing to misread, so
14
+ * assembly yields the empty set. The reader is otherwise **hard-fail by design** —
15
+ * a silently-empty catalog read is the "every dependency looks newly added" bug,
16
+ * because catalog output is load-bearing for snapshot diffing.
17
+ *
18
+ * @public
19
+ */
20
+ var CatalogAssemblyError = class extends Schema.TaggedErrorClass()("CatalogAssemblyError", {
21
+ /**
22
+ * Which input failed: `manifest` for a file-level or top-level shape problem,
23
+ * `catalog` for a malformed catalog block or the double-default duplication,
24
+ * `hooks` for a config-dependency `pnpmfile.cjs` load or replay failure.
25
+ */
26
+ source: Schema.Literals([
27
+ "manifest",
28
+ "catalog",
29
+ "hooks"
30
+ ]),
31
+ /** The file, the catalog name, or the config dependency name. */
32
+ path: Schema.String,
33
+ /** The originating failure. */
34
+ cause: Schema.Defect()
35
+ }) {
36
+ /** Renders the failing source into a one-line message. */
37
+ get message() {
38
+ return `Failed to assemble catalogs from ${this.source} ${this.path}`;
39
+ }
40
+ };
41
+
42
+ //#endregion
43
+ export { CatalogAssemblyError };
@@ -0,0 +1,154 @@
1
+ import { WorkspaceDiscovery } from "./WorkspaceDiscovery.js";
2
+ import { DependencyGraph } from "./DependencyGraph.js";
3
+ import { Context, Effect, Layer, Schema } from "effect";
4
+ import { Git } from "@effected/git";
5
+
6
+ //#region src/ChangeDetector.ts
7
+ /**
8
+ * Which git refs to compare, and whether to fold in the working tree.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { ChangeDetectionOptions } from "@effected/workspaces";
13
+ *
14
+ * ChangeDetectionOptions.make({}); // HEAD~1...HEAD
15
+ * ChangeDetectionOptions.make({ base: "origin/main" }); // against a branch
16
+ * ```
17
+ *
18
+ * @public
19
+ */
20
+ var ChangeDetectionOptions = class extends Schema.Class("ChangeDetectionOptions")({
21
+ /**
22
+ * The ref to compare against.
23
+ *
24
+ * @defaultValue `"HEAD~1"`
25
+ */
26
+ base: Schema.String.pipe(Schema.withDecodingDefaultKey(Effect.succeed("HEAD~1")), Schema.withConstructorDefault(Effect.succeed("HEAD~1"))),
27
+ /**
28
+ * The ref to compare to.
29
+ *
30
+ * @defaultValue `"HEAD"`
31
+ */
32
+ head: Schema.String.pipe(Schema.withDecodingDefaultKey(Effect.succeed("HEAD")), Schema.withConstructorDefault(Effect.succeed("HEAD"))),
33
+ /**
34
+ * Whether to include staged, unstaged and untracked working-tree changes on
35
+ * top of the committed range.
36
+ *
37
+ * @defaultValue `false`
38
+ */
39
+ includeUncommitted: Schema.Boolean.pipe(Schema.withDecodingDefaultKey(Effect.succeed(false)), Schema.withConstructorDefault(Effect.succeed(false)))
40
+ }) {};
41
+ /**
42
+ * Raised when change detection cannot proceed for a reason that is not one of
43
+ * git's own typed failures — the wrapper for "detection has no ground to stand
44
+ * on".
45
+ *
46
+ * @remarks
47
+ * A git command that merely *fails* surfaces as one of `@effected/git`'s typed
48
+ * errors ({@link ChangeDetectionFailure} carries `GitCommandError`,
49
+ * `NotARepositoryError` and `UnknownRefError`) rather than being flattened into
50
+ * this wrapper — a caller branches on git's taxonomy directly.
51
+ *
52
+ * @public
53
+ */
54
+ var ChangeDetectionError = class extends Schema.TaggedErrorClass()("ChangeDetectionError", {
55
+ /** The operation that could not run. */
56
+ operation: Schema.String,
57
+ /** The originating failure. */
58
+ cause: Schema.Defect()
59
+ }) {
60
+ /** Renders the failed operation into a one-line message. */
61
+ get message() {
62
+ return `Change detection failed during ${this.operation}`;
63
+ }
64
+ };
65
+ /**
66
+ * Detects which workspace packages a git range touches.
67
+ *
68
+ * @remarks
69
+ * Three depths on one service, cheapest first — raw file paths, the packages
70
+ * owning them, and the transitive blast radius through the dependency graph.
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * import { ChangeDetectionOptions, ChangeDetector } from "@effected/workspaces";
75
+ * import { Effect } from "effect";
76
+ *
77
+ * const program = Effect.gen(function* () {
78
+ * const detector = yield* ChangeDetector;
79
+ * const affected = yield* detector.affectedPackages(
80
+ * ChangeDetectionOptions.make({ base: "origin/main" }),
81
+ * );
82
+ * return affected.map((pkg) => pkg.name);
83
+ * });
84
+ * ```
85
+ *
86
+ * @public
87
+ */
88
+ var ChangeDetector = class ChangeDetector extends Context.Service()("@effected/workspaces/ChangeDetector") {
89
+ /** Builds the service over `Git` and {@link WorkspaceDiscovery}. */
90
+ static make = Effect.gen(function* () {
91
+ const git = yield* Git;
92
+ const discovery = yield* WorkspaceDiscovery;
93
+ /**
94
+ * The changed files of the range, plus the working tree when asked.
95
+ *
96
+ * Every query runs with `relative: true`, so `Git` reports paths relative
97
+ * to `cwd` — the workspace root — and excludes changes outside it. That is
98
+ * what makes a workspace nested inside a larger git repository correct:
99
+ * without it, `git diff` reports repository-top-level paths that resolve to
100
+ * nothing under the workspace root. `Git` owns the `--relative` mechanics;
101
+ * this detector only asks for the relative mode.
102
+ */
103
+ const filesOf = (root, options) => Effect.gen(function* () {
104
+ const committed = yield* git.changedFiles(root, {
105
+ base: options.base,
106
+ head: options.head,
107
+ relative: true
108
+ });
109
+ if (!options.includeUncommitted) return [...committed].sort();
110
+ const working = yield* git.workingChanges(root, { relative: true });
111
+ return [.../* @__PURE__ */ new Set([...committed, ...working])].sort();
112
+ });
113
+ /**
114
+ * Every git query above reports paths relative to `cwd` — the workspace
115
+ * root — so the workspace root is the correct bridge to the absolute paths
116
+ * discovery indexes by. That holds whether or not the workspace root and
117
+ * the git root coincide, which is the point: a workspace nested inside a
118
+ * larger repository is legitimate.
119
+ */
120
+ const rootOf = () => discovery.info().pipe(Effect.map((info) => info.root));
121
+ const packagesOf = (options) => Effect.gen(function* () {
122
+ const root = yield* rootOf();
123
+ const absolute = (yield* filesOf(root, options)).map((file) => file.startsWith("/") ? file : `${root}/${file}`);
124
+ return yield* discovery.resolveFiles(absolute);
125
+ });
126
+ return {
127
+ changedFiles: Effect.fn("ChangeDetector.changedFiles")(function* (options) {
128
+ const root = yield* rootOf();
129
+ const files = yield* filesOf(root, options ?? ChangeDetectionOptions.make({}));
130
+ yield* Effect.logDebug("Changed files detected").pipe(Effect.annotateLogs("workspace.files.count", files.length));
131
+ return files;
132
+ }),
133
+ changedPackages: Effect.fn("ChangeDetector.changedPackages")(function* (options) {
134
+ const packages = yield* packagesOf(options ?? ChangeDetectionOptions.make({}));
135
+ yield* Effect.logDebug("Changed packages detected").pipe(Effect.annotateLogs("workspace.packages.count", packages.length));
136
+ return packages;
137
+ }),
138
+ affectedPackages: Effect.fn("ChangeDetector.affectedPackages")(function* (options) {
139
+ const changed = yield* packagesOf(options ?? ChangeDetectionOptions.make({}));
140
+ const all = yield* discovery.listPackages();
141
+ const names = yield* DependencyGraph.make({ packages: all }).affectedBy(changed.map((pkg) => pkg.name));
142
+ const byName = new Map(all.map((pkg) => [pkg.name, pkg]));
143
+ const affected = names.map((name) => byName.get(name)).filter((pkg) => pkg !== void 0);
144
+ yield* Effect.logDebug("Affected packages detected").pipe(Effect.annotateLogs("workspace.packages.count", affected.length));
145
+ return affected;
146
+ })
147
+ };
148
+ });
149
+ /** The live layer. */
150
+ static layer = Layer.effect(ChangeDetector, ChangeDetector.make);
151
+ };
152
+
153
+ //#endregion
154
+ export { ChangeDetectionError, ChangeDetectionOptions, ChangeDetector };
@@ -0,0 +1,153 @@
1
+ import { CatalogAssemblyError } from "./CatalogAssemblyError.js";
2
+ import { normalize } from "./internal/catalogs.js";
3
+ import { Context, Effect, Layer, Predicate, Result } from "effect";
4
+ import { join } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+
7
+ //#region src/ConfigDependencyHooks.ts
8
+ /** Whether `value` is a non-null, non-array object. */
9
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
10
+ /**
11
+ * The file URL a Node ESM `ERR_MODULE_NOT_FOUND` reports as unresolvable, or
12
+ * `undefined` when the failure is anything else.
13
+ *
14
+ * @remarks
15
+ * Node sets `err.url` (an own string property) to the offending module's URL.
16
+ * For an absent *entry* module it is that entry's own URL; for a module the entry
17
+ * `import`s that fails to resolve it is the **nested** module's URL. That
18
+ * distinction is exactly what separates "this pnpmfile does not exist" (a
19
+ * legitimate skip, `err.url` equal to the candidate) from "the pnpmfile's own
20
+ * import failed to resolve" (a real error, `err.url` pointing elsewhere) — a
21
+ * discrimination `err.code` alone cannot make, since both raise
22
+ * `ERR_MODULE_NOT_FOUND`. Verified against Node's ESM loader: the entry-absent
23
+ * case yields `err.url === pathToFileURL(candidate).href`; the nested case yields
24
+ * the nested module's URL.
25
+ */
26
+ const moduleNotFoundUrl = (cause) => isObject(cause) && cause.code === "ERR_MODULE_NOT_FOUND" && typeof cause.url === "string" ? cause.url : void 0;
27
+ /**
28
+ * Whether a config-dependency `name` carries a `..` path segment. A scoped name
29
+ * legitimately contains `/` (`@scope/pkg`), so only a `..` *segment* is rejected —
30
+ * it would traverse out of `.pnpm-config` and feed an attacker-chosen path to the
31
+ * dynamic `import()` below.
32
+ */
33
+ const hasTraversalSegment = (name) => name.split(/[/\\]/).includes("..");
34
+ /** Turn the seed record into the pnpm hook config, with the default catalog split out under `catalog`. */
35
+ const seedToConfig = (seed) => {
36
+ const catalogs = {};
37
+ let catalog = {};
38
+ for (const [name, entries] of Object.entries(seed)) if (name === "default") catalog = { ...entries };
39
+ else catalogs[name] = { ...entries };
40
+ return {
41
+ catalog,
42
+ catalogs
43
+ };
44
+ };
45
+ /** Read the catalog slice back out of whatever a hook returned, falling back to the prior config. */
46
+ const configOf = (value, fallback) => {
47
+ if (!isObject(value)) return fallback;
48
+ return {
49
+ catalog: isObject(value.catalog) ? value.catalog : fallback.catalog,
50
+ catalogs: isObject(value.catalogs) ? value.catalogs : fallback.catalogs
51
+ };
52
+ };
53
+ /** Fold the hook config back into the normalized `catalog name → dependency → range` record. */
54
+ const configToEntries = (config) => {
55
+ const raw = { ...config.catalogs };
56
+ if (Object.keys(config.catalog).length > 0) raw.default = {
57
+ ...isObject(raw.default) ? raw.default : {},
58
+ ...config.catalog
59
+ };
60
+ return normalize(raw);
61
+ };
62
+ /** Locate the `updateConfig` hook across the CJS/ESM export shapes a `pnpmfile.cjs` can present. */
63
+ const updateConfigOf = (mod) => {
64
+ for (const candidate of [mod, isObject(mod) ? mod.default : void 0]) {
65
+ if (!isObject(candidate)) continue;
66
+ const hooks = candidate.hooks;
67
+ if (isObject(hooks) && Predicate.isFunction(hooks.updateConfig)) return hooks.updateConfig;
68
+ if (Predicate.isFunction(candidate.updateConfig)) return candidate.updateConfig;
69
+ }
70
+ };
71
+ /**
72
+ * Replays a workspace's `configDependencies` `updateConfig` hooks over the inline
73
+ * catalogs — the opt-in seam that lets hook-injected catalogs participate in
74
+ * assembly.
75
+ *
76
+ * @remarks
77
+ * A contract-only service: it declares the shape and ships two layers, never a
78
+ * baked-in default. {@link ConfigDependencyHooks.layerNoop} executes no
79
+ * config-dependency code (it returns the seed untouched) and is what the default
80
+ * {@link WorkspaceCatalogs} layer wires; {@link ConfigDependencyHooks.layerLive}
81
+ * dynamically imports each `pnpmfile.cjs` and replays it, and is wired only by the
82
+ * explicit `WorkspaceCatalogs.layerWithConfigDependencies` opt-in.
83
+ *
84
+ * @public
85
+ */
86
+ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service()("@effected/workspaces/ConfigDependencyHooks") {
87
+ /**
88
+ * The no-op layer: `inject` returns the seed unchanged and never touches a
89
+ * config dependency. The default {@link WorkspaceCatalogs} layer wires this, so
90
+ * the default catalog path provably executes no config-dependency code.
91
+ */
92
+ static layerNoop = Layer.succeed(ConfigDependencyHooks, { inject: (_root, _configDependencies, seed) => Effect.succeed(seed) });
93
+ /**
94
+ * The live layer: dynamically imports each config dependency's `pnpmfile.cjs`
95
+ * (in process, no subprocess) and replays its `updateConfig` hook over the
96
+ * seed, in declaration order. A dependency without a `pnpmfile.cjs` contributes
97
+ * nothing; a dependency whose file fails to load or replay fails typed with a
98
+ * `hooks`-source {@link CatalogAssemblyError}, never a silent skip.
99
+ *
100
+ * @remarks
101
+ * Node-coupled by design — the `node:fs` / `node:path` / `node:url` imports are
102
+ * the sanctioned Node-only overlay, matching the other seams here. Only ever
103
+ * wired by `WorkspaceCatalogs.layerWithConfigDependencies`.
104
+ */
105
+ static layerLive = Layer.succeed(ConfigDependencyHooks, { inject: (root, configDependencies, seed) => Effect.gen(function* () {
106
+ const names = Object.keys(configDependencies);
107
+ if (names.length === 0) return seed;
108
+ let config = seedToConfig(seed);
109
+ for (const name of names) {
110
+ if (hasTraversalSegment(name)) return yield* Effect.fail(new CatalogAssemblyError({
111
+ source: "hooks",
112
+ path: name,
113
+ cause: /* @__PURE__ */ new Error(`config dependency name has a '..' path segment: ${name}`)
114
+ }));
115
+ let loaded;
116
+ let found = false;
117
+ for (const filename of ["pnpmfile.mjs", "pnpmfile.cjs"]) {
118
+ const candidateUrl = pathToFileURL(join(root, "node_modules", ".pnpm-config", name, filename)).href;
119
+ const result = yield* Effect.result(Effect.tryPromise({
120
+ try: () => import(candidateUrl),
121
+ catch: (cause) => cause
122
+ }));
123
+ if (Result.isSuccess(result)) {
124
+ loaded = result.success;
125
+ found = true;
126
+ break;
127
+ }
128
+ if (moduleNotFoundUrl(result.failure) === candidateUrl) continue;
129
+ return yield* Effect.fail(new CatalogAssemblyError({
130
+ source: "hooks",
131
+ path: name,
132
+ cause: result.failure
133
+ }));
134
+ }
135
+ if (!found) continue;
136
+ const updateConfig = updateConfigOf(loaded);
137
+ if (updateConfig === void 0) continue;
138
+ const currentConfig = config;
139
+ config = yield* Effect.try({
140
+ try: () => configOf(updateConfig(currentConfig), currentConfig),
141
+ catch: (cause) => new CatalogAssemblyError({
142
+ source: "hooks",
143
+ path: name,
144
+ cause
145
+ })
146
+ });
147
+ }
148
+ return configToEntries(config);
149
+ }) });
150
+ };
151
+
152
+ //#endregion
153
+ export { ConfigDependencyHooks };
@@ -0,0 +1,241 @@
1
+ import { WorkspacePackage } from "./WorkspacePackage.js";
2
+ import { PackageNotFoundError } from "./WorkspaceDiscovery.js";
3
+ import { Effect, Schema } from "effect";
4
+
5
+ //#region src/DependencyGraph.ts
6
+ /**
7
+ * Raised when the workspace dependency graph cannot be topologically ordered
8
+ * because it contains a cycle.
9
+ *
10
+ * @remarks
11
+ * `cycle` lists every package still carrying unsatisfied dependencies when
12
+ * Kahn's algorithm stalls — the strongly-connected residue, sorted. It is the
13
+ * set to break, not necessarily a single ordered loop.
14
+ *
15
+ * @public
16
+ */
17
+ var CyclicDependencyError = class extends Schema.TaggedErrorClass()("CyclicDependencyError", {
18
+ /** The packages participating in the cycle. */
19
+ cycle: Schema.Array(Schema.String) }) {
20
+ /** Renders the cycle members into a one-line message. */
21
+ get message() {
22
+ return `Cyclic workspace dependencies among: ${this.cycle.join(", ")}`;
23
+ }
24
+ };
25
+ /**
26
+ * The directed graph of dependencies **between workspace packages**. External
27
+ * npm dependencies are not nodes.
28
+ *
29
+ * @remarks
30
+ * A pure value over the discovered package list, with the edge indexes built
31
+ * lazily into `#private` fields the schema never encodes. Edges are drawn from
32
+ * `dependencies`, `devDependencies`, `peerDependencies` and
33
+ * `optionalDependencies`; a self-edge is dropped.
34
+ *
35
+ * Total accessors never fail; the four fallible operations are `Effect.fn`
36
+ * boundaries.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * import { DependencyGraph, WorkspaceDiscovery } from "@effected/workspaces";
41
+ * import { Effect } from "effect";
42
+ *
43
+ * const program = Effect.gen(function* () {
44
+ * const discovery = yield* WorkspaceDiscovery;
45
+ * const graph = DependencyGraph.make({ packages: yield* discovery.listPackages() });
46
+ * return yield* graph.levels();
47
+ * });
48
+ * ```
49
+ *
50
+ * @public
51
+ */
52
+ var DependencyGraph = class extends Schema.Class("DependencyGraph")({
53
+ /** The workspace packages the graph is drawn over. */
54
+ packages: Schema.Array(WorkspacePackage) }) {
55
+ #edges;
56
+ #index() {
57
+ if (this.#edges !== void 0) return this.#edges;
58
+ const names = new Set(this.packages.map((pkg) => pkg.name));
59
+ const forward = /* @__PURE__ */ new Map();
60
+ const reverse = /* @__PURE__ */ new Map();
61
+ for (const name of names) {
62
+ forward.set(name, /* @__PURE__ */ new Set());
63
+ reverse.set(name, /* @__PURE__ */ new Set());
64
+ }
65
+ for (const pkg of this.packages) for (const dependency of Object.keys(pkg.allDependencies)) {
66
+ if (!names.has(dependency) || dependency === pkg.name) continue;
67
+ forward.get(pkg.name)?.add(dependency);
68
+ reverse.get(dependency)?.add(pkg.name);
69
+ }
70
+ this.#edges = {
71
+ forward,
72
+ reverse
73
+ };
74
+ return this.#edges;
75
+ }
76
+ /** Every workspace package name, sorted. Total. */
77
+ get names() {
78
+ return [...this.#index().forward.keys()].sort();
79
+ }
80
+ /** The adjacency map: name → the names it depends on. Total. */
81
+ get adjacency() {
82
+ return this.#index().forward;
83
+ }
84
+ /**
85
+ * Whether the graph contains a cycle. Total.
86
+ *
87
+ * @remarks
88
+ * An explicit-stack DFS with an on-stack set — never recursive, so a long
89
+ * dependency chain cannot overflow.
90
+ */
91
+ get hasCycle() {
92
+ const { forward } = this.#index();
93
+ const visited = /* @__PURE__ */ new Set();
94
+ const onStack = /* @__PURE__ */ new Set();
95
+ for (const start of forward.keys()) {
96
+ if (visited.has(start)) continue;
97
+ const stack = [{
98
+ node: start,
99
+ deps: [...forward.get(start) ?? []],
100
+ cursor: 0
101
+ }];
102
+ visited.add(start);
103
+ onStack.add(start);
104
+ while (stack.length > 0) {
105
+ const frame = stack[stack.length - 1];
106
+ if (frame.cursor >= frame.deps.length) {
107
+ onStack.delete(frame.node);
108
+ stack.pop();
109
+ continue;
110
+ }
111
+ const next = frame.deps[frame.cursor];
112
+ frame.cursor += 1;
113
+ if (onStack.has(next)) return true;
114
+ if (visited.has(next)) continue;
115
+ visited.add(next);
116
+ onStack.add(next);
117
+ stack.push({
118
+ node: next,
119
+ deps: [...forward.get(next) ?? []],
120
+ cursor: 0
121
+ });
122
+ }
123
+ }
124
+ return false;
125
+ }
126
+ /** The workspace packages `name` depends on, sorted. */
127
+ dependenciesOf = Effect.fn("DependencyGraph.dependenciesOf")((name) => {
128
+ const deps = this.#index().forward.get(name);
129
+ return deps === void 0 ? Effect.fail(new PackageNotFoundError({
130
+ name,
131
+ available: this.names
132
+ })) : Effect.succeed([...deps].sort());
133
+ });
134
+ /** The workspace packages that depend on `name`, sorted. */
135
+ dependentsOf = Effect.fn("DependencyGraph.dependentsOf")((name) => {
136
+ const dependents = this.#index().reverse.get(name);
137
+ return dependents === void 0 ? Effect.fail(new PackageNotFoundError({
138
+ name,
139
+ available: this.names
140
+ })) : Effect.succeed([...dependents].sort());
141
+ });
142
+ /**
143
+ * `name` plus every package that transitively depends on it — the blast
144
+ * radius of a change. Sorted; includes `name` itself.
145
+ */
146
+ affectedBy = Effect.fn("DependencyGraph.affectedBy")((names) => {
147
+ const { reverse } = this.#index();
148
+ const affected = /* @__PURE__ */ new Set();
149
+ const queue = [...names];
150
+ while (queue.length > 0) {
151
+ const current = queue.shift();
152
+ /* v8 ignore next */
153
+ if (current === void 0) break;
154
+ if (affected.has(current)) continue;
155
+ affected.add(current);
156
+ for (const dependent of reverse.get(current) ?? []) if (!affected.has(dependent)) queue.push(dependent);
157
+ }
158
+ return Effect.succeed([...affected].sort());
159
+ });
160
+ /**
161
+ * Packages grouped into parallel build levels: level 0 depends on nothing in
162
+ * the workspace, level *n* depends only on levels below it.
163
+ *
164
+ * @remarks
165
+ * Kahn's algorithm over the reverse-edge index — linear, where v3's rescanned
166
+ * the whole adjacency map per processed node. Each level is sorted
167
+ * lexicographically, so the output is deterministic.
168
+ */
169
+ levels = Effect.fn("DependencyGraph.levels")(() => Effect.suspend(() => Effect.succeed(kahn(this.#index()))).pipe(Effect.flatMap((result) => result.stalled.length > 0 ? Effect.fail(new CyclicDependencyError({ cycle: result.stalled })) : Effect.succeed(result.levels))));
170
+ /** The flattened topological order — `levels()` concatenated. */
171
+ sort = Effect.fn("DependencyGraph.sort")(() => this.levels().pipe(Effect.map((levels) => levels.flat())));
172
+ /**
173
+ * A topological order over `names` plus their transitive workspace
174
+ * dependencies — the build order for a subset.
175
+ */
176
+ sortSubset = Effect.fn("DependencyGraph.sortSubset")((names) => Effect.suspend(() => {
177
+ const { forward } = this.#index();
178
+ for (const name of names) if (!forward.has(name)) return Effect.fail(new PackageNotFoundError({
179
+ name,
180
+ available: this.names
181
+ }));
182
+ const needed = /* @__PURE__ */ new Set();
183
+ const queue = [...names];
184
+ while (queue.length > 0) {
185
+ const current = queue.shift();
186
+ /* v8 ignore next */
187
+ if (current === void 0) break;
188
+ if (needed.has(current)) continue;
189
+ needed.add(current);
190
+ for (const dependency of forward.get(current) ?? []) if (!needed.has(dependency)) queue.push(dependency);
191
+ }
192
+ const subForward = /* @__PURE__ */ new Map();
193
+ const subReverse = /* @__PURE__ */ new Map();
194
+ for (const node of needed) subReverse.set(node, /* @__PURE__ */ new Set());
195
+ for (const node of needed) {
196
+ const deps = new Set([...forward.get(node) ?? []].filter((dep) => needed.has(dep)));
197
+ subForward.set(node, deps);
198
+ for (const dep of deps) subReverse.get(dep)?.add(node);
199
+ }
200
+ const result = kahn({
201
+ forward: subForward,
202
+ reverse: subReverse
203
+ });
204
+ return result.stalled.length > 0 ? Effect.fail(new CyclicDependencyError({ cycle: result.stalled })) : Effect.succeed(result.levels.flat());
205
+ }));
206
+ };
207
+ /**
208
+ * Kahn's algorithm. `forward[A] = {B}` reads "A depends on B", so level 0 is
209
+ * the set with an out-degree of zero and each completed level decrements its
210
+ * dependents through the reverse index.
211
+ */
212
+ const kahn = (edges) => {
213
+ const remaining = /* @__PURE__ */ new Map();
214
+ for (const [node, deps] of edges.forward) remaining.set(node, deps.size);
215
+ const levels = [];
216
+ let current = [...remaining.entries()].filter(([, count]) => count === 0).map(([node]) => node);
217
+ current.sort();
218
+ while (current.length > 0) {
219
+ levels.push(current);
220
+ const next = [];
221
+ for (const done of current) {
222
+ remaining.delete(done);
223
+ for (const dependent of edges.reverse.get(done) ?? []) {
224
+ const count = remaining.get(dependent);
225
+ if (count === void 0) continue;
226
+ const decremented = count - 1;
227
+ remaining.set(dependent, decremented);
228
+ if (decremented === 0) next.push(dependent);
229
+ }
230
+ }
231
+ next.sort();
232
+ current = next;
233
+ }
234
+ return {
235
+ levels,
236
+ stalled: [...remaining.keys()].sort()
237
+ };
238
+ };
239
+
240
+ //#endregion
241
+ export { CyclicDependencyError, DependencyGraph };
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C. Spencer Beggs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.