@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.
- package/CatalogAssemblyError.js +43 -0
- package/ChangeDetector.js +154 -0
- package/ConfigDependencyHooks.js +153 -0
- package/DependencyGraph.js +241 -0
- package/LICENSE +21 -0
- package/LockfileReader.js +118 -0
- package/PackageManagerName.js +248 -0
- package/Publishability.js +73 -0
- package/README.md +165 -0
- package/WorkspaceCatalogs.js +392 -0
- package/WorkspaceDiscovery.js +338 -0
- package/WorkspacePackage.js +247 -0
- package/WorkspaceRoot.js +90 -0
- package/WorkspaceSnapshots.js +0 -0
- package/WorkspaceStateSnapshot.js +165 -0
- package/Workspaces.js +113 -0
- package/WorkspacesSync.js +205 -0
- package/index.d.ts +1559 -0
- package/index.js +17 -0
- package/internal/catalogs.js +70 -0
- package/internal/enumerate.js +75 -0
- package/internal/limits.js +20 -0
- package/internal/patterns.js +62 -0
- package/internal/traverse.js +94 -0
- package/package.json +61 -0
- package/tsdoc-metadata.json +11 -0
|
@@ -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.
|