@effected/workspaces 0.14.2 → 0.15.1
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/ConfigDependencyHooks.js +84 -16
- package/PeerCheck.js +395 -0
- package/README.md +40 -1
- package/VersioningStrategy.js +5 -1
- package/WorkspaceCatalogs.js +46 -3
- package/WorkspacesSync.js +4 -2
- package/index.d.ts +313 -3
- package/index.js +3 -2
- package/internal/enumerate.js +8 -8
- package/package.json +2 -2
package/ConfigDependencyHooks.js
CHANGED
|
@@ -7,6 +7,27 @@ import { Run } from "@effected/commands";
|
|
|
7
7
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
8
8
|
|
|
9
9
|
//#region src/ConfigDependencyHooks.ts
|
|
10
|
+
/**
|
|
11
|
+
* The empty {@link PeerDependencyRules}: every axis present and empty.
|
|
12
|
+
*
|
|
13
|
+
* @remarks
|
|
14
|
+
* Exported so that "I assert this workspace's rules are empty" is **one token**
|
|
15
|
+
* rather than three hand-written empty axes. That matters where the distinction
|
|
16
|
+
* is load-bearing — supplying rules asserts they were looked up, while omitting
|
|
17
|
+
* them asserts nothing — and a caller spelling the object out by hand will
|
|
18
|
+
* eventually fill two of the three axes and mean the third.
|
|
19
|
+
*
|
|
20
|
+
* Frozen, since it is shared.
|
|
21
|
+
*
|
|
22
|
+
* @public
|
|
23
|
+
*/
|
|
24
|
+
const NoPeerDependencyRules = Object.freeze({
|
|
25
|
+
allowedVersions: Object.freeze({}),
|
|
26
|
+
ignoreMissing: Object.freeze([]),
|
|
27
|
+
allowAny: Object.freeze([])
|
|
28
|
+
});
|
|
29
|
+
/** Internal alias, kept short at the many call sites in this module. */
|
|
30
|
+
const NO_PEER_RULES = NoPeerDependencyRules;
|
|
10
31
|
/** Whether `value` is a non-null, non-array object. */
|
|
11
32
|
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
12
33
|
/**
|
|
@@ -34,7 +55,7 @@ const moduleNotFoundUrl = (cause) => isObject(cause) && cause.code === "ERR_MODU
|
|
|
34
55
|
*/
|
|
35
56
|
const hasTraversalSegment = (name) => name.split(/[/\\]/).includes("..");
|
|
36
57
|
/** Turn the seed record into the pnpm hook config, with the default catalog split out under `catalog`. */
|
|
37
|
-
const seedToConfig = (seed) => {
|
|
58
|
+
const seedToConfig = (seed, rules) => {
|
|
38
59
|
const catalogs = {};
|
|
39
60
|
let catalog = {};
|
|
40
61
|
for (const [name, entries] of Object.entries(seed)) if (name === "default") catalog = { ...entries };
|
|
@@ -43,7 +64,8 @@ const seedToConfig = (seed) => {
|
|
|
43
64
|
catalog,
|
|
44
65
|
catalogs,
|
|
45
66
|
minimumReleaseAge: void 0,
|
|
46
|
-
minimumReleaseAgeExclude: void 0
|
|
67
|
+
minimumReleaseAgeExclude: void 0,
|
|
68
|
+
peerDependencyRules: rules
|
|
47
69
|
};
|
|
48
70
|
};
|
|
49
71
|
/** A finite number if `value` is one, else the prior threaded value — a garbage age is dropped, not fatal. */
|
|
@@ -68,9 +90,32 @@ const configOf = (value, fallback) => {
|
|
|
68
90
|
catalog: isObject(value.catalog) ? value.catalog : fallback.catalog,
|
|
69
91
|
catalogs: isObject(value.catalogs) ? value.catalogs : fallback.catalogs,
|
|
70
92
|
minimumReleaseAge: finiteNumberOr(value.minimumReleaseAge, fallback.minimumReleaseAge),
|
|
71
|
-
minimumReleaseAgeExclude: stringArrayOr(value.minimumReleaseAgeExclude, fallback.minimumReleaseAgeExclude)
|
|
93
|
+
minimumReleaseAgeExclude: stringArrayOr(value.minimumReleaseAgeExclude, fallback.minimumReleaseAgeExclude),
|
|
94
|
+
peerDependencyRules: peerRulesOr(value.peerDependencyRules, fallback.peerDependencyRules)
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* A peer-rules block if `value` is one, else the prior threaded value — a
|
|
99
|
+
* malformed block is dropped, not fatal, matching the other slices.
|
|
100
|
+
*
|
|
101
|
+
* Each axis is normalized independently: a hook that rewrites `allowedVersions`
|
|
102
|
+
* and leaves `ignoreMissing` alone must not blank the latter.
|
|
103
|
+
*/
|
|
104
|
+
const stringRecordOr = (value, fallback) => isObject(value) && Object.values(value).every((entry) => typeof entry === "string") ? value : fallback;
|
|
105
|
+
const peerRulesOr = (value, fallback) => {
|
|
106
|
+
if (!isObject(value)) return fallback;
|
|
107
|
+
const allowed = stringRecordOr(value.allowedVersions, fallback?.allowedVersions);
|
|
108
|
+
const ignoreMissing = stringArrayOr(value.ignoreMissing, fallback?.ignoreMissing);
|
|
109
|
+
const allowAny = stringArrayOr(value.allowAny, fallback?.allowAny);
|
|
110
|
+
if (allowed === void 0 && ignoreMissing === void 0 && allowAny === void 0) return fallback;
|
|
111
|
+
return {
|
|
112
|
+
allowedVersions: allowed ?? {},
|
|
113
|
+
ignoreMissing: ignoreMissing ?? [],
|
|
114
|
+
allowAny: allowAny ?? []
|
|
72
115
|
};
|
|
73
116
|
};
|
|
117
|
+
/** Project the threaded config's rules into the slice, defaulting every axis to empty. */
|
|
118
|
+
const peerRulesOf = (config) => config.peerDependencyRules ?? NO_PEER_RULES;
|
|
74
119
|
/**
|
|
75
120
|
* Project the threaded config's release-age keys into a partial gate
|
|
76
121
|
* contribution, omitting a key the hooks never set (never an explicit
|
|
@@ -124,13 +169,23 @@ const updateConfigOf = (mod) => {
|
|
|
124
169
|
* noise on stdout.
|
|
125
170
|
*/
|
|
126
171
|
const REPLAY_SCRIPT = `
|
|
127
|
-
const [root, seedJson, ...names] = process.argv.slice(1);
|
|
172
|
+
const [root, seedJson, rulesJson, ...names] = process.argv.slice(1);
|
|
128
173
|
const { pathToFileURL } = await import("node:url");
|
|
129
174
|
const { join } = await import("node:path");
|
|
130
175
|
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
131
176
|
const finiteNumberOr = (value, fallback) => (typeof value === "number" && Number.isFinite(value) ? value : fallback);
|
|
132
177
|
const stringArrayOr = (value, fallback) =>
|
|
133
178
|
Array.isArray(value) && value.every((entry) => typeof entry === "string") ? value : fallback;
|
|
179
|
+
const stringRecordOr = (value, fallback) =>
|
|
180
|
+
isObject(value) && Object.values(value).every((entry) => typeof entry === "string") ? value : fallback;
|
|
181
|
+
const peerRulesOr = (value, fallback) => {
|
|
182
|
+
if (!isObject(value)) return fallback;
|
|
183
|
+
const allowed = stringRecordOr(value.allowedVersions, fallback && fallback.allowedVersions);
|
|
184
|
+
const ignoreMissing = stringArrayOr(value.ignoreMissing, fallback && fallback.ignoreMissing);
|
|
185
|
+
const allowAny = stringArrayOr(value.allowAny, fallback && fallback.allowAny);
|
|
186
|
+
if (allowed === undefined && ignoreMissing === undefined && allowAny === undefined) return fallback;
|
|
187
|
+
return { allowedVersions: allowed || {}, ignoreMissing: ignoreMissing || [], allowAny: allowAny || [] };
|
|
188
|
+
};
|
|
134
189
|
const configOf = (value, fallback) =>
|
|
135
190
|
isObject(value)
|
|
136
191
|
? {
|
|
@@ -138,6 +193,7 @@ const configOf = (value, fallback) =>
|
|
|
138
193
|
catalogs: isObject(value.catalogs) ? value.catalogs : fallback.catalogs,
|
|
139
194
|
minimumReleaseAge: finiteNumberOr(value.minimumReleaseAge, fallback.minimumReleaseAge),
|
|
140
195
|
minimumReleaseAgeExclude: stringArrayOr(value.minimumReleaseAgeExclude, fallback.minimumReleaseAgeExclude),
|
|
196
|
+
peerDependencyRules: peerRulesOr(value.peerDependencyRules, fallback.peerDependencyRules),
|
|
141
197
|
}
|
|
142
198
|
: fallback;
|
|
143
199
|
const failure = (name, cause) => ({
|
|
@@ -153,7 +209,13 @@ const replay = async () => {
|
|
|
153
209
|
if (name === "default") catalog = { ...entries };
|
|
154
210
|
else catalogs[name] = { ...entries };
|
|
155
211
|
}
|
|
156
|
-
let config = {
|
|
212
|
+
let config = {
|
|
213
|
+
catalog,
|
|
214
|
+
catalogs,
|
|
215
|
+
minimumReleaseAge: undefined,
|
|
216
|
+
minimumReleaseAgeExclude: undefined,
|
|
217
|
+
peerDependencyRules: JSON.parse(rulesJson),
|
|
218
|
+
};
|
|
157
219
|
for (const name of names) {
|
|
158
220
|
let loaded;
|
|
159
221
|
let found = false;
|
|
@@ -254,9 +316,10 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
|
|
|
254
316
|
* config dependency. The default {@link WorkspaceCatalogs} layer wires this, so
|
|
255
317
|
* the default catalog path provably executes no config-dependency code.
|
|
256
318
|
*/
|
|
257
|
-
static layerNoop = Layer.succeed(ConfigDependencyHooks, { inject: (_root, _configDependencies, seed) => Effect.succeed({
|
|
319
|
+
static layerNoop = Layer.succeed(ConfigDependencyHooks, { inject: (_root, _configDependencies, seed, rules) => Effect.succeed({
|
|
258
320
|
catalogs: seed,
|
|
259
|
-
releaseAge: {}
|
|
321
|
+
releaseAge: {},
|
|
322
|
+
peerDependencyRules: rules ?? NO_PEER_RULES
|
|
260
323
|
}) });
|
|
261
324
|
/**
|
|
262
325
|
* The live layer: dynamically imports each config dependency's `pnpmfile.cjs`
|
|
@@ -274,13 +337,14 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
|
|
|
274
337
|
* import, so this layer runs on either runtime. Only ever wired by
|
|
275
338
|
* `WorkspaceCatalogs.layerWithConfigDependencies`.
|
|
276
339
|
*/
|
|
277
|
-
static layerLive = Layer.succeed(ConfigDependencyHooks, { inject: (root, configDependencies, seed) => Effect.gen(function* () {
|
|
340
|
+
static layerLive = Layer.succeed(ConfigDependencyHooks, { inject: (root, configDependencies, seed, rules) => Effect.gen(function* () {
|
|
278
341
|
const names = Object.keys(configDependencies);
|
|
279
342
|
if (names.length === 0) return {
|
|
280
343
|
catalogs: seed,
|
|
281
|
-
releaseAge: {}
|
|
344
|
+
releaseAge: {},
|
|
345
|
+
peerDependencyRules: rules ?? NO_PEER_RULES
|
|
282
346
|
};
|
|
283
|
-
let config = seedToConfig(seed);
|
|
347
|
+
let config = seedToConfig(seed, rules);
|
|
284
348
|
for (const name of names) {
|
|
285
349
|
if (hasTraversalSegment(name)) return yield* Effect.fail(new CatalogAssemblyError({
|
|
286
350
|
source: "hooks",
|
|
@@ -326,7 +390,8 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
|
|
|
326
390
|
}
|
|
327
391
|
return {
|
|
328
392
|
catalogs: configToEntries(config),
|
|
329
|
-
releaseAge: releaseAgeOf(config)
|
|
393
|
+
releaseAge: releaseAgeOf(config),
|
|
394
|
+
peerDependencyRules: peerRulesOf(config)
|
|
330
395
|
};
|
|
331
396
|
}) });
|
|
332
397
|
/**
|
|
@@ -382,11 +447,12 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
|
|
|
382
447
|
*/
|
|
383
448
|
static layerSubprocess = Layer.effect(ConfigDependencyHooks, Effect.gen(function* () {
|
|
384
449
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
385
|
-
return { inject: (root, configDependencies, seed) => Effect.gen(function* () {
|
|
450
|
+
return { inject: (root, configDependencies, seed, rules) => Effect.gen(function* () {
|
|
386
451
|
const names = Object.keys(configDependencies);
|
|
387
452
|
if (names.length === 0) return {
|
|
388
453
|
catalogs: seed,
|
|
389
|
-
releaseAge: {}
|
|
454
|
+
releaseAge: {},
|
|
455
|
+
peerDependencyRules: rules ?? NO_PEER_RULES
|
|
390
456
|
};
|
|
391
457
|
for (const name of names) if (hasTraversalSegment(name)) return yield* Effect.fail(new CatalogAssemblyError({
|
|
392
458
|
source: "hooks",
|
|
@@ -399,6 +465,7 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
|
|
|
399
465
|
REPLAY_SCRIPT,
|
|
400
466
|
root,
|
|
401
467
|
JSON.stringify(seed),
|
|
468
|
+
JSON.stringify(rules ?? NO_PEER_RULES),
|
|
402
469
|
...names
|
|
403
470
|
]);
|
|
404
471
|
const payload = yield* Run.jsonLine(command, ReplayPayload, { timeout: REPLAY_TIMEOUT }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.catch((cause) => Effect.fail(new CatalogAssemblyError({
|
|
@@ -411,14 +478,15 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
|
|
|
411
478
|
path: payload.name ?? root,
|
|
412
479
|
cause: replayFailureCause(payload)
|
|
413
480
|
}));
|
|
414
|
-
const config = configOf(payload.config, seedToConfig(seed));
|
|
481
|
+
const config = configOf(payload.config, seedToConfig(seed, rules));
|
|
415
482
|
return {
|
|
416
483
|
catalogs: configToEntries(config),
|
|
417
|
-
releaseAge: releaseAgeOf(config)
|
|
484
|
+
releaseAge: releaseAgeOf(config),
|
|
485
|
+
peerDependencyRules: peerRulesOf(config)
|
|
418
486
|
};
|
|
419
487
|
}) };
|
|
420
488
|
}));
|
|
421
489
|
};
|
|
422
490
|
|
|
423
491
|
//#endregion
|
|
424
|
-
export { ConfigDependencyHooks };
|
|
492
|
+
export { ConfigDependencyHooks, NoPeerDependencyRules };
|
package/PeerCheck.js
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { Result, Schema } from "effect";
|
|
2
|
+
import { Range, SemVer } from "@effected/semver";
|
|
3
|
+
|
|
4
|
+
//#region src/PeerCheck.ts
|
|
5
|
+
/**
|
|
6
|
+
* One link in the chain from an importer to the package that declared an
|
|
7
|
+
* unsatisfied peer.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* Mirrors the shape `pnpm peers check --json` reports, so the two can be
|
|
11
|
+
* compared directly.
|
|
12
|
+
*
|
|
13
|
+
* @public
|
|
14
|
+
*/
|
|
15
|
+
var PeerParent = class extends Schema.Class("PeerParent")({
|
|
16
|
+
/** The package name. */
|
|
17
|
+
name: Schema.NonEmptyString,
|
|
18
|
+
/** The resolved version of that package. */
|
|
19
|
+
version: Schema.String
|
|
20
|
+
}) {};
|
|
21
|
+
/**
|
|
22
|
+
* One peer dependency that is declared but not satisfied.
|
|
23
|
+
*
|
|
24
|
+
* @remarks
|
|
25
|
+
* `found` carries the version that actually resolved, or `null` when nothing
|
|
26
|
+
* resolved at all. There is deliberately **no separate discriminant** between
|
|
27
|
+
* "missing" and "unmet": `found === null` is the whole distinction, and a
|
|
28
|
+
* renderer reads it directly.
|
|
29
|
+
*
|
|
30
|
+
* `optional` must be respected by any gate — an unsatisfied *optional* peer is
|
|
31
|
+
* normal and should not fail a build, which is why the flag travels with the
|
|
32
|
+
* row rather than being filtered out here. {@link PeerCheck.required} is the
|
|
33
|
+
* convenience for the common case.
|
|
34
|
+
*
|
|
35
|
+
* `parents` is the path from the importer down to the package that declared
|
|
36
|
+
* the peer, nearest-to-the-importer first. It is a path rather than a single
|
|
37
|
+
* package because a peer declared by a transitive dependency is still that
|
|
38
|
+
* importer's problem, and the chain is what makes the report actionable.
|
|
39
|
+
*
|
|
40
|
+
* A package an importer reaches by more than one path yields **one** row, not
|
|
41
|
+
* one per path, carrying the path the walk reached first — which is what
|
|
42
|
+
* `pnpm peers check` reports for the same graph. Read `parents` as *a* route to
|
|
43
|
+
* the declaring package, never as the complete set of them.
|
|
44
|
+
*
|
|
45
|
+
* @public
|
|
46
|
+
*/
|
|
47
|
+
var UnsatisfiedPeer = class extends Schema.Class("UnsatisfiedPeer")({
|
|
48
|
+
/** The importer path the problem belongs to (`"."` for the root). */
|
|
49
|
+
importer: Schema.NonEmptyString,
|
|
50
|
+
/** The peer dependency's name. */
|
|
51
|
+
dependency: Schema.NonEmptyString,
|
|
52
|
+
/** The range the declaring package asked for. */
|
|
53
|
+
wanted: Schema.String,
|
|
54
|
+
/** The version that resolved, or `null` when nothing resolved. */
|
|
55
|
+
found: Schema.NullOr(Schema.String),
|
|
56
|
+
/** Whether the declaring package marked the peer optional. */
|
|
57
|
+
optional: Schema.Boolean,
|
|
58
|
+
/** The path from the importer to the declaring package. */
|
|
59
|
+
parents: Schema.Array(PeerParent)
|
|
60
|
+
}) {};
|
|
61
|
+
/**
|
|
62
|
+
* The formats whose lockfiles record peer resolution.
|
|
63
|
+
*
|
|
64
|
+
* An allowlist rather than a `!== "yarn"` denylist, so a format added to
|
|
65
|
+
* `Lockfile["format"]` is unsupported until someone says otherwise. A denylist
|
|
66
|
+
* would report the new format `supported: true` with an empty `unsatisfied`
|
|
67
|
+
* before any parser records peer resolution for it — a clean bill of health
|
|
68
|
+
* produced by a limitation, which is the failure this module exists to avoid.
|
|
69
|
+
*
|
|
70
|
+
* @internal
|
|
71
|
+
*/
|
|
72
|
+
const PEER_RESOLVING_FORMATS = /* @__PURE__ */ new Set([
|
|
73
|
+
"npm",
|
|
74
|
+
"pnpm",
|
|
75
|
+
"bun"
|
|
76
|
+
]);
|
|
77
|
+
/** @internal */
|
|
78
|
+
const supportsPeerResolution = (format) => PEER_RESOLVING_FORMATS.has(format);
|
|
79
|
+
/**
|
|
80
|
+
* The result of checking a lockfile for unsatisfied peer dependencies.
|
|
81
|
+
*
|
|
82
|
+
* @remarks
|
|
83
|
+
* A report rather than a bare array, because an empty array is the most
|
|
84
|
+
* dangerous success shape this domain has: it is indistinguishable from "this
|
|
85
|
+
* format cannot be checked". `supported` and `unresolvedImporters` make the
|
|
86
|
+
* difference legible, so a consumer cannot read a limitation as a clean bill
|
|
87
|
+
* of health.
|
|
88
|
+
*
|
|
89
|
+
* Pure and total — construct it with {@link PeerCheck.run}, which never fails.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```ts
|
|
93
|
+
* import { PeerCheck } from "@effected/workspaces";
|
|
94
|
+
* import { Lockfile } from "@effected/lockfiles";
|
|
95
|
+
* import { Effect } from "effect";
|
|
96
|
+
*
|
|
97
|
+
* const program = Effect.gen(function* () {
|
|
98
|
+
* const lockfile = yield* Lockfile.parse(text, { format: "pnpm" });
|
|
99
|
+
* const report = PeerCheck.run(lockfile);
|
|
100
|
+
* return report.supported ? report.required : [];
|
|
101
|
+
* });
|
|
102
|
+
* ```
|
|
103
|
+
*
|
|
104
|
+
* @public
|
|
105
|
+
*/
|
|
106
|
+
var PeerCheck = class PeerCheck extends Schema.Class("PeerCheck")({
|
|
107
|
+
/**
|
|
108
|
+
* Whether the lockfile's format records peer resolution at all.
|
|
109
|
+
*
|
|
110
|
+
* `false` for yarn: yarn resolves peers **virtually**, giving a
|
|
111
|
+
* peer-bearing package one `@virtual:` locator per consumer, and the
|
|
112
|
+
* lockfile does not record which virtual instance satisfied which peer.
|
|
113
|
+
* The answer is not recoverable, so it is not fabricated — `unsatisfied`
|
|
114
|
+
* is empty and this flag says why.
|
|
115
|
+
*/
|
|
116
|
+
supported: Schema.Boolean,
|
|
117
|
+
/** Every unsatisfied peer found, optional ones included. */
|
|
118
|
+
unsatisfied: Schema.Array(UnsatisfiedPeer),
|
|
119
|
+
/**
|
|
120
|
+
* Importers whose dependencies could not be resolved to instances, so no
|
|
121
|
+
* verdict was reached for them.
|
|
122
|
+
*
|
|
123
|
+
* @remarks
|
|
124
|
+
* In practice this is the **root importer under npm and bun**: neither
|
|
125
|
+
* records a resolved version per importer dependency, and neither emits a
|
|
126
|
+
* package row for the root, so there is nothing to join on. pnpm records a
|
|
127
|
+
* version per importer dependency and is unaffected.
|
|
128
|
+
*
|
|
129
|
+
* Reported rather than silently skipped, for the same reason `supported`
|
|
130
|
+
* exists: a gate that sees no rows is entitled to know whether that means
|
|
131
|
+
* "clean" or "not looked at".
|
|
132
|
+
*/
|
|
133
|
+
unresolvedImporters: Schema.Array(Schema.String),
|
|
134
|
+
/**
|
|
135
|
+
* Why this report is not a complete answer, or empty when it is.
|
|
136
|
+
*
|
|
137
|
+
* @remarks
|
|
138
|
+
* See {@link UnverifiedReason}. A gate must treat a non-empty `unverified`
|
|
139
|
+
* as **not proven clean** rather than as a pass: both reasons mean some
|
|
140
|
+
* finding may be missing or spurious, and failing closed is the requirement.
|
|
141
|
+
*/
|
|
142
|
+
unverified: Schema.Array(Schema.Literals(["peerRulesNotApplied", "unresolvedEdge"]))
|
|
143
|
+
}) {
|
|
144
|
+
/** The unsatisfied peers a gate should act on — the non-optional ones. */
|
|
145
|
+
get required() {
|
|
146
|
+
return this.unsatisfied.filter((row) => !row.optional);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Compute the unsatisfied peer dependencies of a parsed lockfile.
|
|
150
|
+
*
|
|
151
|
+
* @remarks
|
|
152
|
+
* Pure, total and format-free: no IO, no error channel, and no knowledge of
|
|
153
|
+
* which package manager wrote the file. Range satisfaction is
|
|
154
|
+
* `@effected/semver`'s, never hand-rolled.
|
|
155
|
+
*
|
|
156
|
+
* The walk starts at each importer's resolved dependencies and follows
|
|
157
|
+
* `resolved` edges, so a peer declared by a *transitive* dependency is
|
|
158
|
+
* attributed to the importer that pulls it in, with the chain in
|
|
159
|
+
* `parents` — matching how `pnpm peers check` attributes them.
|
|
160
|
+
*
|
|
161
|
+
* Three judgements are deliberate:
|
|
162
|
+
*
|
|
163
|
+
* - **A required peer with nothing resolved is reported** (`found: null`),
|
|
164
|
+
* whether or not the declared range is parseable — a peer with no
|
|
165
|
+
* provider is a fact about the graph that needs no range arithmetic.
|
|
166
|
+
* - **An ABSENT optional peer is not reported at all**, because that is
|
|
167
|
+
* what optional means. An optional peer resolved at the *wrong* version
|
|
168
|
+
* still is, carrying `optional: true`. Both halves match
|
|
169
|
+
* `pnpm peers check`, whose `missing` section contains only required
|
|
170
|
+
* peers while its `bad` section carries optional ones.
|
|
171
|
+
* - **An unparseable range or version with something resolved is skipped.**
|
|
172
|
+
* The check cannot judge it, and asserting "unsatisfied" on a comparison
|
|
173
|
+
* that was never performed would be a wrong answer rather than a gap.
|
|
174
|
+
* - **A workspace-linked provider counts.** Resolution is followed through
|
|
175
|
+
* whatever the edge names, including a workspace row.
|
|
176
|
+
*
|
|
177
|
+
* Known limits it does not paper over: yarn (see `supported`), the npm and
|
|
178
|
+
* bun root importer (see `unresolvedImporters`), and pnpm recording no peer
|
|
179
|
+
* declarations for workspace projects themselves — under pnpm a workspace
|
|
180
|
+
* package's *own* unsatisfied peers are not in the lockfile at all, and
|
|
181
|
+
* `pnpm peers check` does not report them either.
|
|
182
|
+
*
|
|
183
|
+
* @param lockfile - a lockfile parsed by `@effected/lockfiles`
|
|
184
|
+
* @returns the report; never fails
|
|
185
|
+
*/
|
|
186
|
+
static run(lockfile, options) {
|
|
187
|
+
const keySupplied = options !== void 0 && "peerDependencyRules" in options;
|
|
188
|
+
const rules = options?.peerDependencyRules;
|
|
189
|
+
const axesUnapplied = rules !== void 0 && (rules.ignoreMissing.length > 0 || rules.allowAny.length > 0);
|
|
190
|
+
const rulesApplied = keySupplied && !axesUnapplied;
|
|
191
|
+
const allowed = rules?.allowedVersions ?? {};
|
|
192
|
+
const unverified = rulesApplied ? [] : ["peerRulesNotApplied"];
|
|
193
|
+
if (!supportsPeerResolution(lockfile.format)) return PeerCheck.make({
|
|
194
|
+
supported: false,
|
|
195
|
+
unsatisfied: [],
|
|
196
|
+
unresolvedImporters: [],
|
|
197
|
+
unverified
|
|
198
|
+
});
|
|
199
|
+
const byId = new Map(lockfile.packages.map((pkg) => [pkg.instanceId, pkg]));
|
|
200
|
+
const workspaceByPath = /* @__PURE__ */ new Map();
|
|
201
|
+
for (const pkg of lockfile.packages) if (pkg.isWorkspace && pkg.relativePath !== void 0) workspaceByPath.set(pkg.relativePath, pkg);
|
|
202
|
+
const rows = [];
|
|
203
|
+
const unresolved = [];
|
|
204
|
+
const seen = /* @__PURE__ */ new Set();
|
|
205
|
+
for (const importer of lockfile.importers) {
|
|
206
|
+
const roots = rootInstances(lockfile, importer.path, workspaceByPath, byId);
|
|
207
|
+
if (roots === void 0) {
|
|
208
|
+
unresolved.push(importer.path);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
collect(importer.path, roots, byId, rows, seen, allowed);
|
|
212
|
+
}
|
|
213
|
+
if (lockfile.packages.some((pkg) => pkg.unresolvedEdges.length > 0)) unverified.push("unresolvedEdge");
|
|
214
|
+
return PeerCheck.make({
|
|
215
|
+
supported: true,
|
|
216
|
+
unsatisfied: rows,
|
|
217
|
+
unresolvedImporters: unresolved,
|
|
218
|
+
unverified
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
/**
|
|
223
|
+
* The instances an importer's dependencies resolved to, plus the importer's own
|
|
224
|
+
* workspace row when the lockfile records one.
|
|
225
|
+
*
|
|
226
|
+
* Returns `undefined` when the importer cannot be resolved at all, which the
|
|
227
|
+
* caller reports rather than treating as "no problems here".
|
|
228
|
+
*
|
|
229
|
+
* @internal
|
|
230
|
+
*/
|
|
231
|
+
const rootInstances = (lockfile, importerPath, workspaceByPath, byId) => {
|
|
232
|
+
const own = workspaceByPath.get(importerPath);
|
|
233
|
+
if (own !== void 0) return [{
|
|
234
|
+
instance: own,
|
|
235
|
+
path: []
|
|
236
|
+
}];
|
|
237
|
+
const importer = lockfile.importer(importerPath);
|
|
238
|
+
if (importer._tag === "None") return void 0;
|
|
239
|
+
const walks = [];
|
|
240
|
+
let resolvable = false;
|
|
241
|
+
for (const dep of importer.value.dependencies) {
|
|
242
|
+
if (dep.version === void 0) continue;
|
|
243
|
+
const composed = byId.get(`${dep.name}@${dep.version}${dep.peerSuffix ?? ""}`);
|
|
244
|
+
if (composed === void 0) continue;
|
|
245
|
+
resolvable = true;
|
|
246
|
+
walks.push({
|
|
247
|
+
instance: composed,
|
|
248
|
+
path: [PeerParent.make({
|
|
249
|
+
name: composed.name,
|
|
250
|
+
version: composed.version
|
|
251
|
+
})]
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
if (!resolvable && importer.value.dependencies.length > 0) return void 0;
|
|
255
|
+
return walks;
|
|
256
|
+
};
|
|
257
|
+
/**
|
|
258
|
+
* Walk the resolution graph from an importer's roots, emitting a row for every
|
|
259
|
+
* unsatisfied peer declared anywhere along the way.
|
|
260
|
+
*
|
|
261
|
+
* @internal
|
|
262
|
+
*/
|
|
263
|
+
const collect = (importerPath, roots, byId, rows, seen, allowed) => {
|
|
264
|
+
const visited = /* @__PURE__ */ new Set();
|
|
265
|
+
const queue = [...roots];
|
|
266
|
+
while (queue.length > 0) {
|
|
267
|
+
const current = queue.shift();
|
|
268
|
+
if (current === void 0) break;
|
|
269
|
+
if (visited.has(current.instance.instanceId)) continue;
|
|
270
|
+
visited.add(current.instance.instanceId);
|
|
271
|
+
for (const [peer, wanted] of Object.entries(current.instance.peerDependencies)) {
|
|
272
|
+
if (peer === "") continue;
|
|
273
|
+
const optional = current.instance.peerDependenciesMeta[peer]?.optional === true;
|
|
274
|
+
const verdict = judge(current.instance, peer, wanted, optional, byId);
|
|
275
|
+
if (verdict === void 0) continue;
|
|
276
|
+
if (suppressedByRule(allowed, current.instance.name, peer, verdict.found)) continue;
|
|
277
|
+
const key = `${importerPath}\u0000${peer}\u0000${current.instance.instanceId}`;
|
|
278
|
+
if (seen.has(key)) continue;
|
|
279
|
+
seen.add(key);
|
|
280
|
+
rows.push(UnsatisfiedPeer.make({
|
|
281
|
+
importer: importerPath,
|
|
282
|
+
dependency: peer,
|
|
283
|
+
wanted,
|
|
284
|
+
found: verdict.found,
|
|
285
|
+
optional,
|
|
286
|
+
parents: current.path
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
for (const targetId of Object.values(current.instance.resolved)) {
|
|
290
|
+
const next = byId.get(targetId);
|
|
291
|
+
if (next === void 0 || visited.has(targetId)) continue;
|
|
292
|
+
queue.push({
|
|
293
|
+
instance: next,
|
|
294
|
+
path: [...current.path, PeerParent.make({
|
|
295
|
+
name: next.name,
|
|
296
|
+
version: next.version
|
|
297
|
+
})]
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
/**
|
|
303
|
+
* The parent name a `peerDependencyRules.allowedVersions` key names, with the
|
|
304
|
+
* parent's version stripped.
|
|
305
|
+
*
|
|
306
|
+
* @remarks
|
|
307
|
+
* **pnpm ignores the parent version in a rule key**, so
|
|
308
|
+
* `"react-dom@18.0.0>react"` suppresses a `react-dom@18.3.1` instance too.
|
|
309
|
+
* Replicating that is not optional: matching on the version would suppress a
|
|
310
|
+
* strictly smaller set than pnpm does, and every row in the difference is a
|
|
311
|
+
* false positive. Measured against pnpm 11.22.0, one axis at a time: a rule
|
|
312
|
+
* keyed at a version the installed parent does not have still suppresses, and
|
|
313
|
+
* so does one keyed at a wildly different version, while a rule keyed on an
|
|
314
|
+
* ANCESTOR of the declaring package suppresses nothing. The parent is the
|
|
315
|
+
* declarer, matched by name.
|
|
316
|
+
*
|
|
317
|
+
* Both spellings occur in the wild and both must work — parent-versioned, as
|
|
318
|
+
* `pnpm:export` materializes into `pnpm-workspace.yaml`, and unversioned, as a
|
|
319
|
+
* config-dependency plugin injects. A scoped name keeps its leading `@`, so the
|
|
320
|
+
* version separator is the LAST `@`, and only when it is not the first
|
|
321
|
+
* character.
|
|
322
|
+
*
|
|
323
|
+
* A key naming no parent at all (`"react"`, with no `>`) never reaches this
|
|
324
|
+
* function: {@link suppressedByRule} matches it against every parent, which is
|
|
325
|
+
* what pnpm does with it.
|
|
326
|
+
*
|
|
327
|
+
* @internal
|
|
328
|
+
*/
|
|
329
|
+
const ruleParentName = (parent) => {
|
|
330
|
+
const at = parent.lastIndexOf("@");
|
|
331
|
+
return at > 0 ? parent.slice(0, at) : parent;
|
|
332
|
+
};
|
|
333
|
+
/**
|
|
334
|
+
* Whether a rule permits the version that actually resolved for this peer.
|
|
335
|
+
*
|
|
336
|
+
* @remarks
|
|
337
|
+
* Only `allowedVersions` is consulted. `ignoreMissing` and `allowAny` are
|
|
338
|
+
* carried through the seam **unmeasured and unwired** — an unmeasured
|
|
339
|
+
* suppression is exactly what produced the false positives this work removes,
|
|
340
|
+
* so no code acts on them until someone measures them. Non-empty, they make the
|
|
341
|
+
* whole report `"peerRulesNotApplied"` (see {@link PeerCheck.run}) rather than
|
|
342
|
+
* being ignored here.
|
|
343
|
+
*
|
|
344
|
+
* A rule cannot rescue a peer that resolved to nothing: with no version there
|
|
345
|
+
* is nothing for the rule to permit, and pnpm's own `missing` section is not
|
|
346
|
+
* suppressed by `allowedVersions` either.
|
|
347
|
+
*
|
|
348
|
+
* @internal
|
|
349
|
+
*/
|
|
350
|
+
const suppressedByRule = (allowed, parentName, peer, found) => {
|
|
351
|
+
if (found === null) return false;
|
|
352
|
+
const version = SemVer.parseResult(found);
|
|
353
|
+
if (Result.isFailure(version)) return false;
|
|
354
|
+
for (const [key, permitted] of Object.entries(allowed)) {
|
|
355
|
+
const separator = key.indexOf(">");
|
|
356
|
+
if (separator === 0) continue;
|
|
357
|
+
if (separator === -1) {
|
|
358
|
+
if (key !== peer) continue;
|
|
359
|
+
} else {
|
|
360
|
+
if (key.slice(separator + 1) !== peer) continue;
|
|
361
|
+
if (ruleParentName(key.slice(0, separator)) !== parentName) continue;
|
|
362
|
+
}
|
|
363
|
+
const range = Range.parseResult(permitted);
|
|
364
|
+
if (Result.isFailure(range)) continue;
|
|
365
|
+
if (Range.satisfies(version.success, range.success)) return true;
|
|
366
|
+
}
|
|
367
|
+
return false;
|
|
368
|
+
};
|
|
369
|
+
/**
|
|
370
|
+
* Decide whether one declared peer is unsatisfied.
|
|
371
|
+
*
|
|
372
|
+
* Returns `undefined` for "satisfied, or not judgeable" — the two cases that
|
|
373
|
+
* produce no row. They are deliberately merged here and separated in the
|
|
374
|
+
* TSDoc on {@link PeerCheck.run}, since neither yields output.
|
|
375
|
+
*
|
|
376
|
+
* @internal
|
|
377
|
+
*/
|
|
378
|
+
const judge = (instance, peer, wanted, optional, byId) => {
|
|
379
|
+
const providerId = instance.resolved[peer];
|
|
380
|
+
const provider = providerId === void 0 ? void 0 : byId.get(providerId);
|
|
381
|
+
if (provider === void 0) {
|
|
382
|
+
if (optional) return void 0;
|
|
383
|
+
if (instance.unresolvedEdges.includes(peer)) return void 0;
|
|
384
|
+
return { found: null };
|
|
385
|
+
}
|
|
386
|
+
if (provider.isWorkspace) return void 0;
|
|
387
|
+
const range = Range.parseResult(wanted);
|
|
388
|
+
const version = SemVer.parseResult(provider.version);
|
|
389
|
+
if (Result.isFailure(range) || Result.isFailure(version)) return void 0;
|
|
390
|
+
if (Range.satisfies(version.success, range.success)) return void 0;
|
|
391
|
+
return { found: provider.version };
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
//#endregion
|
|
395
|
+
export { PeerCheck, PeerParent, UnsatisfiedPeer };
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://nodejs.org/)
|
|
6
6
|
[](https://www.typescriptlang.org/)
|
|
7
7
|
|
|
8
|
-
Monorepo workspace tooling for [Effect](https://effect.website) v4: find the workspace root, enumerate its packages, walk the dependency graph, detect the package manager, resolve pnpm catalogs, read the lockfile and work out which packages a git range touches. Every capability is a service you provide at the edge and swap in tests. Works with npm, pnpm, yarn Berry and bun.
|
|
8
|
+
Monorepo workspace tooling for [Effect](https://effect.website) v4: find the workspace root, enumerate its packages, walk the dependency graph, detect the package manager, resolve pnpm catalogs, read the lockfile, check it for unsatisfied peer dependencies and work out which packages a git range touches. Every capability is a service you provide at the edge and swap in tests. Works with npm, pnpm, yarn Berry and bun.
|
|
9
9
|
|
|
10
10
|
> **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
|
|
11
11
|
> development against a single pinned Effect v4 prerelease. Packages graduate to
|
|
@@ -142,6 +142,43 @@ const program = Effect.gen(function* () {
|
|
|
142
142
|
|
|
143
143
|
A specifier the workspace cannot answer fails typed as `UnresolvedDependencyError`: at the manifest level "no catalog entry" means the manifest cannot be projected to concrete ranges.
|
|
144
144
|
|
|
145
|
+
## Peer dependency checking
|
|
146
|
+
|
|
147
|
+
`PeerCheck.run(lockfile, options?)` reports unsatisfied peer dependencies as a pure value: no IO, no error channel, nothing in `R`, and no per-manager traversal logic. It reads `lockfile.format` once, to reject a format whose lockfile does not record peer resolution; past that gate the walk is the same for every manager. The answer comes from the resolved graph `@effected/lockfiles` normalizes, not from shelling out to a package manager's own peer command — bun has none, so the subprocess route cannot answer for every manager the rest of this package supports.
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { LockfileReader, PeerCheck, WorkspaceCatalogs } from "@effected/workspaces";
|
|
151
|
+
import { Effect } from "effect";
|
|
152
|
+
|
|
153
|
+
const program = Effect.gen(function* () {
|
|
154
|
+
const reader = yield* LockfileReader;
|
|
155
|
+
const catalogs = yield* WorkspaceCatalogs;
|
|
156
|
+
|
|
157
|
+
const lockfile = yield* reader.read();
|
|
158
|
+
// Presence of the key is the assertion — see below.
|
|
159
|
+
const report = PeerCheck.run(lockfile, { peerDependencyRules: yield* catalogs.peerDependencyRules() });
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
clean:
|
|
163
|
+
report.supported &&
|
|
164
|
+
report.unresolvedImporters.length === 0 &&
|
|
165
|
+
report.unverified.length === 0 &&
|
|
166
|
+
report.required.length === 0,
|
|
167
|
+
required: report.required.length,
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
// { clean: whether the workspace is proven clean, required: count of non-optional findings }
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**An empty `unsatisfied` is not the same as clean**, and reading it that way is the mistake this report's shape exists to prevent. Three other fields carry the difference between "nothing is wrong" and "nothing was checked", and a gate must read all four:
|
|
174
|
+
|
|
175
|
+
- `supported` is `false` for yarn, which resolves peers virtually and does not record which virtual instance satisfied which peer. The answer is unrecoverable, so it is not fabricated.
|
|
176
|
+
- `unresolvedImporters` names importers that could not be joined to package instances — in practice the root importer under npm and bun, neither of which records a resolved version per importer dependency.
|
|
177
|
+
- `unverified` says why the report is not a complete answer: `"peerRulesNotApplied"` when the suppression policy could not be applied, `"unresolvedEdge"` when an instance records an edge the model could not name. Both mean fail closed.
|
|
178
|
+
- `required` is the getter for the rows a gate should act on — the non-optional ones. An unsatisfied *optional* peer is normal, so `optional` travels with the row rather than being filtered out at the source.
|
|
179
|
+
|
|
180
|
+
`WorkspaceCatalogs.peerDependencyRules()` returns the workspace's effective merged pnpm suppression rules, which the lockfile records nowhere; without them a checker reports findings pnpm itself calls clean. **Presence of the `peerDependencyRules` option key is the assertion, not its contents.** Passing `NoPeerDependencyRules` asserts the workspace has none, so the report carries no `"peerRulesNotApplied"` — though it can still be unverified for another reason, or unsupported; omitting the key says nobody looked, and always yields `"peerRulesNotApplied"`. Only `allowedVersions` is applied — rules populating `ignoreMissing` or `allowAny` describe a policy this package does not replicate, so they fail closed through the same reason rather than being silently ignored. All three key spellings pnpm accepts are honoured: `parent@version>peer` (what `pnpm:export` writes), `parent>peer` (what a config-dependency plugin injects) and a bare `peer`, which pnpm applies to every parent that declares it.
|
|
181
|
+
|
|
145
182
|
## The synchronous escape hatch
|
|
146
183
|
|
|
147
184
|
Vitest's config-time project discovery cannot await. Two functions exist for exactly that case, and they run synchronously over file and path operations you supply. On Node you do not have to write them: the `@effected/workspaces/node-sync` subpath exports `nodeSyncOps`, the ready-made `node:fs` and `node:path` bindings, so adopting the sync path is one extra import.
|
|
@@ -242,6 +279,8 @@ A name miss in the derived `getPackage` fails with the service's own typed `Pack
|
|
|
242
279
|
- `PackageManagerDetector` — npm, pnpm, yarn or bun from lockfiles and the `packageManager` field.
|
|
243
280
|
- `WorkspaceCatalogs` — pnpm catalog assembly and `catalog:` resolution, on pnpm's own catalog packages; `releaseAgeGate()` assembles the effective `@effected/npm` `ReleaseAgeGate` from inline `pnpm-workspace.yaml` release-age keys and replayed hook contributions, strictest-wins, in the same pass as the catalogs.
|
|
244
281
|
- `LockfileReader` — locate and parse the workspace's lockfile through `@effected/lockfiles`.
|
|
282
|
+
- `PeerCheck` — unsatisfied peer-dependency detection as a pure, total value over a parsed lockfile, with `UnsatisfiedPeer` and `PeerParent` as the report's rows. Read `supported`, `unresolvedImporters` and `unverified` alongside `unsatisfied`: an empty finding list is a clean bill of health only when those three say so.
|
|
283
|
+
- `NoPeerDependencyRules` / `PeerDependencyRules` — the effective pnpm suppression policy `WorkspaceCatalogs.peerDependencyRules()` assembles, and the "I assert none apply" value for callers that have checked.
|
|
245
284
|
- `ChangeDetector` — git-range change detection over `@effected/git`'s `Git` service; swap the layer to mock it with no repository.
|
|
246
285
|
- `PublishabilityDetector` — whether a package publishes and to where, as a `PublishTarget` (registry, directory, access, provenance). No composite provides one: pick `PublishabilityDetector.layerNpm` (standard npm semantics) or `.layerNone` (nothing publishes) and provide it explicitly.
|
|
247
286
|
- `ReleaseTag` / `TrackingTag` — release-tag formatting (`ReleaseTag.single` / `.scoped`, strict SemVer by default with no `v` prefix) and the floating major/minor alias derivation GitHub Actions-style consumers expect (`v1`, `v1.2`), plus `classifyTag` to tell a release tag from a tracking alias.
|
package/VersioningStrategy.js
CHANGED
|
@@ -90,8 +90,12 @@ var VersioningStrategy = class VersioningStrategy extends Schema.Class("Versioni
|
|
|
90
90
|
const discovery = yield* WorkspaceDiscovery;
|
|
91
91
|
const publishability = yield* PublishabilityDetector;
|
|
92
92
|
const packages = yield* discovery.listPackages();
|
|
93
|
+
const detected = yield* Effect.forEach(packages, (candidate) => publishability.detect(candidate).pipe(Effect.map((targets) => ({
|
|
94
|
+
name: candidate.name,
|
|
95
|
+
publishable: targets.length > 0
|
|
96
|
+
}))), { concurrency: 10 });
|
|
93
97
|
const publishable = [];
|
|
94
|
-
for (const candidate of
|
|
98
|
+
for (const candidate of detected) if (candidate.publishable) publishable.push(candidate.name);
|
|
95
99
|
return VersioningStrategy.classify({
|
|
96
100
|
packages: publishable,
|
|
97
101
|
...options?.fixedGroups !== void 0 && { fixedGroups: options.fixedGroups }
|
package/WorkspaceCatalogs.js
CHANGED
|
@@ -211,6 +211,41 @@ const inlineReleaseAge = (document) => {
|
|
|
211
211
|
cause
|
|
212
212
|
})));
|
|
213
213
|
};
|
|
214
|
+
/**
|
|
215
|
+
* The `peerDependencyRules` a `pnpm-workspace.yaml` declares inline — the half
|
|
216
|
+
* `pnpm:export` materializes into the file, as opposed to the half a config
|
|
217
|
+
* dependency injects at replay time.
|
|
218
|
+
*
|
|
219
|
+
* @remarks
|
|
220
|
+
* **Tolerant, unlike the catalog blocks**, and the asymmetry is deliberate: a
|
|
221
|
+
* malformed catalog block must hard-fail because a silently-empty catalog makes
|
|
222
|
+
* every dependency look newly added, whereas a malformed rules block costs only
|
|
223
|
+
* suppression — the failure mode is reporting a peer pnpm would have hidden,
|
|
224
|
+
* which is visible and safe. Failing the whole assembly over it would take the
|
|
225
|
+
* catalogs down with it.
|
|
226
|
+
*
|
|
227
|
+
* Every axis is read independently, so a malformed `ignoreMissing` does not
|
|
228
|
+
* discard a well-formed `allowedVersions`.
|
|
229
|
+
*/
|
|
230
|
+
const EMPTY_PEER_RULES = {
|
|
231
|
+
allowedVersions: {},
|
|
232
|
+
ignoreMissing: [],
|
|
233
|
+
allowAny: []
|
|
234
|
+
};
|
|
235
|
+
const inlinePeerDependencyRules = (document) => {
|
|
236
|
+
if (!isObject(document) || !isObject(document.peerDependencyRules)) return EMPTY_PEER_RULES;
|
|
237
|
+
const block = document.peerDependencyRules;
|
|
238
|
+
const allowedVersions = {};
|
|
239
|
+
if (isObject(block.allowedVersions)) {
|
|
240
|
+
for (const [key, value] of Object.entries(block.allowedVersions)) if (typeof value === "string") allowedVersions[key] = value;
|
|
241
|
+
}
|
|
242
|
+
const strings = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
|
|
243
|
+
return {
|
|
244
|
+
allowedVersions,
|
|
245
|
+
ignoreMissing: strings(block.ignoreMissing),
|
|
246
|
+
allowAny: strings(block.allowAny)
|
|
247
|
+
};
|
|
248
|
+
};
|
|
214
249
|
/** A hard-fail catalog-assembly failure naming the malformed part of a `workspaces` field. */
|
|
215
250
|
const malformed = (source, path, detail) => new CatalogAssemblyError({
|
|
216
251
|
source,
|
|
@@ -343,6 +378,7 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
|
|
|
343
378
|
let injected;
|
|
344
379
|
let inlineGate = {};
|
|
345
380
|
let hookGate = {};
|
|
381
|
+
let peerDependencyRules = EMPTY_PEER_RULES;
|
|
346
382
|
if (hasPnpmWorkspace) {
|
|
347
383
|
const text = yield* fs.readFileString(workspaceYaml).pipe(Effect.mapError((cause) => new CatalogAssemblyError({
|
|
348
384
|
source: "manifest",
|
|
@@ -357,15 +393,17 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
|
|
|
357
393
|
yield* validatePnpmWorkspaceCatalogs(document);
|
|
358
394
|
inline = CatalogSet.fromCatalogs(inlineCatalogs(catalogBlocksOf(document)));
|
|
359
395
|
inlineGate = yield* inlineReleaseAge(document);
|
|
360
|
-
const injection = yield* hooks.inject(root, configDependenciesOf(document), inline.entries);
|
|
396
|
+
const injection = yield* hooks.inject(root, configDependenciesOf(document), inline.entries, inlinePeerDependencyRules(document));
|
|
361
397
|
injected = CatalogSet.fromCatalogs(injection.catalogs);
|
|
362
398
|
hookGate = injection.releaseAge;
|
|
399
|
+
peerDependencyRules = injection.peerDependencyRules;
|
|
363
400
|
} else {
|
|
364
401
|
const manifestPath = path.join(root, "package.json");
|
|
365
402
|
if (!(yield* probeExists(manifestPath))) return {
|
|
366
403
|
catalogs: fromLockfile,
|
|
367
404
|
releaseAgeGate: ReleaseAgeGate.combine(),
|
|
368
|
-
importerVersions
|
|
405
|
+
importerVersions,
|
|
406
|
+
peerDependencyRules: EMPTY_PEER_RULES
|
|
369
407
|
};
|
|
370
408
|
const text = yield* fs.readFileString(manifestPath).pipe(Effect.mapError((cause) => new CatalogAssemblyError({
|
|
371
409
|
source: "manifest",
|
|
@@ -385,7 +423,8 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
|
|
|
385
423
|
return {
|
|
386
424
|
catalogs: assembled,
|
|
387
425
|
releaseAgeGate,
|
|
388
|
-
importerVersions
|
|
426
|
+
importerVersions,
|
|
427
|
+
peerDependencyRules
|
|
389
428
|
};
|
|
390
429
|
});
|
|
391
430
|
const [resolveOnce, invalidate] = yield* Effect.cachedInvalidateWithTTL(assemble, Duration.infinity);
|
|
@@ -397,6 +436,9 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
|
|
|
397
436
|
resolveSpecifier: Effect.fn("WorkspaceCatalogs.resolveSpecifier")(function* (dependency, specifier) {
|
|
398
437
|
return (yield* memo).catalogs.resolveSpecifier(dependency, specifier);
|
|
399
438
|
}),
|
|
439
|
+
peerDependencyRules: Effect.fn("WorkspaceCatalogs.peerDependencyRules")(function* () {
|
|
440
|
+
return (yield* memo).peerDependencyRules;
|
|
441
|
+
}),
|
|
400
442
|
releaseAgeGate: Effect.fn("WorkspaceCatalogs.releaseAgeGate")(function* () {
|
|
401
443
|
return (yield* memo).releaseAgeGate;
|
|
402
444
|
}),
|
|
@@ -483,6 +525,7 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
|
|
|
483
525
|
return {
|
|
484
526
|
set: () => unstubbed("set"),
|
|
485
527
|
resolveSpecifier: set !== void 0 ? (dependency, specifier) => Effect.map(set(), (catalogs) => catalogs.resolveSpecifier(dependency, specifier)) : () => unstubbed("resolveSpecifier"),
|
|
528
|
+
peerDependencyRules: () => unstubbed("peerDependencyRules"),
|
|
486
529
|
releaseAgeGate: () => unstubbed("releaseAgeGate"),
|
|
487
530
|
importerVersions: () => unstubbed("importerVersions"),
|
|
488
531
|
...overrides
|
package/WorkspacesSync.js
CHANGED
|
@@ -196,8 +196,11 @@ const getWorkspacePackagesSync = (root, options) => {
|
|
|
196
196
|
const compiled = Effect.runSyncExit(GlobSet.compile(patterns));
|
|
197
197
|
if (Exit.isFailure(compiled)) return [];
|
|
198
198
|
const globs = compiled.value;
|
|
199
|
+
const excludes = globs.excludes;
|
|
200
|
+
const isExcluded = excludes.length === 0 ? (_) => false : (relative) => excludes.some((exclude) => exclude.matches(relative));
|
|
199
201
|
const included = /* @__PURE__ */ new Map();
|
|
200
202
|
for (const literal of globs.literals) {
|
|
203
|
+
if (isExcluded(literal)) continue;
|
|
201
204
|
const absolute = path.join(root, literal);
|
|
202
205
|
if (isPackage(options, absolute)) included.set(literal, absolute);
|
|
203
206
|
}
|
|
@@ -224,7 +227,7 @@ const getWorkspacePackagesSync = (root, options) => {
|
|
|
224
227
|
stopped = true;
|
|
225
228
|
break;
|
|
226
229
|
}
|
|
227
|
-
if (wildcard.matches(relative) && isPackage(options, absolute)) included.set(relative, absolute);
|
|
230
|
+
if (wildcard.matches(relative) && !isExcluded(relative) && isPackage(options, absolute)) included.set(relative, absolute);
|
|
228
231
|
if (!wildcard.crossesSegments) continue;
|
|
229
232
|
traversal.push(current, relative, absolute);
|
|
230
233
|
}
|
|
@@ -233,7 +236,6 @@ const getWorkspacePackagesSync = (root, options) => {
|
|
|
233
236
|
const members = [];
|
|
234
237
|
for (const [relativePath, absolute] of [...included.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
235
238
|
if (relativePath === "." || absolute === root) continue;
|
|
236
|
-
if (globs.excludes.some((exclude) => exclude.matches(relativePath))) continue;
|
|
237
239
|
const pkg = readPackageSync(options, root, absolute, relativePath);
|
|
238
240
|
if (pkg !== null) members.push(pkg);
|
|
239
241
|
}
|
package/index.d.ts
CHANGED
|
@@ -797,6 +797,52 @@ declare class ChangeDetector extends ChangeDetector_base {
|
|
|
797
797
|
}
|
|
798
798
|
//#endregion
|
|
799
799
|
//#region src/ConfigDependencyHooks.d.ts
|
|
800
|
+
/**
|
|
801
|
+
* pnpm's `peerDependencyRules` block — the suppression policy pnpm applies
|
|
802
|
+
* **after** computing peer violations, in pnpm's own shape.
|
|
803
|
+
*
|
|
804
|
+
* @remarks
|
|
805
|
+
* The shape is pnpm's rather than ours because that is what comes back off the
|
|
806
|
+
* threaded config: measured against `@savvy-web/pnpm-plugin-silk@0.27.0`, a
|
|
807
|
+
* replayed hook returns `{ allowedVersions, ignoreMissing, allowAny }` intact,
|
|
808
|
+
* needing no reshaping.
|
|
809
|
+
*
|
|
810
|
+
* Two keys are **carried but not yet consumed**. `ignoreMissing` and
|
|
811
|
+
* `allowAny` are separate suppression axes that have not been measured, and an
|
|
812
|
+
* unmeasured suppression is precisely what produced the false positives this
|
|
813
|
+
* seam exists to remove — so they travel through the seam and no kit code acts
|
|
814
|
+
* on them. Only `allowedVersions` is consumed today.
|
|
815
|
+
*
|
|
816
|
+
* `allowedVersions` keys come in two spellings in the wild, and both must be
|
|
817
|
+
* handled: parent-versioned (`"@effect/ai-anthropic@4.0.0-rc.109>effect"`, as
|
|
818
|
+
* `pnpm:export` materializes them into `pnpm-workspace.yaml`) and unversioned
|
|
819
|
+
* (`"@effect/vitest>vitest"`, as a config-dependency plugin injects them).
|
|
820
|
+
*
|
|
821
|
+
* @public
|
|
822
|
+
*/
|
|
823
|
+
interface PeerDependencyRules {
|
|
824
|
+
/** `parent>peer` → the peer version or range the rule permits. */
|
|
825
|
+
readonly allowedVersions: Readonly<Record<string, string>>;
|
|
826
|
+
/** Peer names whose absence pnpm does not report. Carried, not consumed. */
|
|
827
|
+
readonly ignoreMissing: ReadonlyArray<string>;
|
|
828
|
+
/** Peer names for which any version is accepted. Carried, not consumed. */
|
|
829
|
+
readonly allowAny: ReadonlyArray<string>;
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* The empty {@link PeerDependencyRules}: every axis present and empty.
|
|
833
|
+
*
|
|
834
|
+
* @remarks
|
|
835
|
+
* Exported so that "I assert this workspace's rules are empty" is **one token**
|
|
836
|
+
* rather than three hand-written empty axes. That matters where the distinction
|
|
837
|
+
* is load-bearing — supplying rules asserts they were looked up, while omitting
|
|
838
|
+
* them asserts nothing — and a caller spelling the object out by hand will
|
|
839
|
+
* eventually fill two of the three axes and mean the third.
|
|
840
|
+
*
|
|
841
|
+
* Frozen, since it is shared.
|
|
842
|
+
*
|
|
843
|
+
* @public
|
|
844
|
+
*/
|
|
845
|
+
declare const NoPeerDependencyRules: PeerDependencyRules;
|
|
800
846
|
/**
|
|
801
847
|
* The result of replaying a workspace's `configDependencies` hooks: the catalogs
|
|
802
848
|
* the hooks yield, and the release-age gate contribution they leave on the
|
|
@@ -816,6 +862,24 @@ interface HookInjection {
|
|
|
816
862
|
readonly catalogs: Readonly<Record<string, Readonly<Record<string, string>>>>;
|
|
817
863
|
/** The release-age gate contribution the replayed hooks leave on the config. */
|
|
818
864
|
readonly releaseAge: PartialReleaseAgeGate;
|
|
865
|
+
/**
|
|
866
|
+
* The **effective** peer-dependency rules: the seeded workspace-file rules
|
|
867
|
+
* with every replayed hook's contribution threaded over them.
|
|
868
|
+
*
|
|
869
|
+
* @remarks
|
|
870
|
+
* Effective rather than hook-only because the rules are **seeded** into the
|
|
871
|
+
* threaded config and the hooks merge onto them, exactly as pnpm seeds its
|
|
872
|
+
* own config and takes back what the hooks return. That is deliberate: a
|
|
873
|
+
* kit-owned merge function would be a second implementation of a rule this
|
|
874
|
+
* seam already enforces, and the two would drift the first time pnpm changed
|
|
875
|
+
* its threading.
|
|
876
|
+
*
|
|
877
|
+
* Measured caveat, and **not a bug to fix**: this repo's plugin *merges*
|
|
878
|
+
* onto the seeded rules; another plugin could overwrite them. Under seeding
|
|
879
|
+
* that is pnpm's own behaviour reproduced — a hook that overwrites
|
|
880
|
+
* overwrites for pnpm too — so do not "repair" it into a merger.
|
|
881
|
+
*/
|
|
882
|
+
readonly peerDependencyRules: PeerDependencyRules;
|
|
819
883
|
}
|
|
820
884
|
/**
|
|
821
885
|
* The {@link ConfigDependencyHooks} service shape.
|
|
@@ -853,8 +917,12 @@ interface ConfigDependencyHooksShape {
|
|
|
853
917
|
* @param configDependencies - The `configDependencies` map (name →
|
|
854
918
|
* version+integrity) declared in `pnpm-workspace.yaml`.
|
|
855
919
|
* @param seed - The inline catalogs, as `catalog name → dependency → range`.
|
|
920
|
+
* @param rules - The workspace file's `peerDependencyRules`, seeded into the
|
|
921
|
+
* threaded config so hooks merge onto them rather than replacing them.
|
|
922
|
+
* Omitted means "the workspace file declares none", which is different
|
|
923
|
+
* from "nobody looked" — the caller owns that distinction.
|
|
856
924
|
*/
|
|
857
|
-
readonly inject: (root: string, configDependencies: Readonly<Record<string, string>>, seed: Readonly<Record<string, Readonly<Record<string, string
|
|
925
|
+
readonly inject: (root: string, configDependencies: Readonly<Record<string, string>>, seed: Readonly<Record<string, Readonly<Record<string, string>>>>, rules?: PeerDependencyRules) => Effect.Effect<HookInjection, CatalogAssemblyError>;
|
|
858
926
|
}
|
|
859
927
|
declare const ConfigDependencyHooks_base: Context.ServiceClass<ConfigDependencyHooks, "@effected/workspaces/ConfigDependencyHooks", ConfigDependencyHooksShape>;
|
|
860
928
|
/**
|
|
@@ -1444,6 +1512,219 @@ declare class LockfileReader extends LockfileReader_base {
|
|
|
1444
1512
|
static readonly layerTest: (overrides?: Partial<LockfileReaderShape>) => Layer.Layer<LockfileReader>;
|
|
1445
1513
|
}
|
|
1446
1514
|
//#endregion
|
|
1515
|
+
//#region src/PeerCheck.d.ts
|
|
1516
|
+
/**
|
|
1517
|
+
* Why a report is not a complete answer.
|
|
1518
|
+
*
|
|
1519
|
+
* @remarks
|
|
1520
|
+
* Closed at exactly two, by measurement rather than by guess:
|
|
1521
|
+
*
|
|
1522
|
+
* - `"peerRulesNotApplied"` — the effective suppression policy was not applied,
|
|
1523
|
+
* so pnpm's post-hoc suppression could not be replicated and some reported
|
|
1524
|
+
* rows may be ones pnpm hides. **Omitting the option always produces this**,
|
|
1525
|
+
* because "nobody looked" and "I looked and there are none" are different
|
|
1526
|
+
* facts. Supplied rules with a non-empty `ignoreMissing` or `allowAny` also
|
|
1527
|
+
* produce it: only `allowedVersions` is applied, so an unimplemented axis
|
|
1528
|
+
* degrades to fail-closed rather than to a wrong answer.
|
|
1529
|
+
* - `"unresolvedEdge"` — some instance records an edge this model could not
|
|
1530
|
+
* name (`ResolvedPackage.unresolvedEdges`), so a peer that edge satisfies
|
|
1531
|
+
* cannot be verified either way.
|
|
1532
|
+
*
|
|
1533
|
+
* Both mean **fail closed**: a gate should treat an unverified report as "not
|
|
1534
|
+
* proven clean" rather than as a pass.
|
|
1535
|
+
*
|
|
1536
|
+
* @public
|
|
1537
|
+
*/
|
|
1538
|
+
type UnverifiedReason = "peerRulesNotApplied" | "unresolvedEdge";
|
|
1539
|
+
/**
|
|
1540
|
+
* Options for {@link PeerCheck.run}.
|
|
1541
|
+
*
|
|
1542
|
+
* @remarks
|
|
1543
|
+
* **Presence of `peerDependencyRules` is the assertion; its contents are the
|
|
1544
|
+
* value.** Supplying `NoPeerDependencyRules` asserts the workspace has none and
|
|
1545
|
+
* yields a verified report; omitting the key entirely says nothing was looked
|
|
1546
|
+
* up and always yields `"peerRulesNotApplied"`. The two are deliberately
|
|
1547
|
+
* different results, because a gate must be able to tell "clean" from
|
|
1548
|
+
* "unchecked".
|
|
1549
|
+
*
|
|
1550
|
+
* **Only `allowedVersions` is applied.** Rules whose `ignoreMissing` or
|
|
1551
|
+
* `allowAny` is non-empty describe a suppression policy this module does not
|
|
1552
|
+
* implement, so they also yield `"peerRulesNotApplied"` — the unimplemented
|
|
1553
|
+
* axes fail closed instead of being silently ignored.
|
|
1554
|
+
*
|
|
1555
|
+
* @public
|
|
1556
|
+
*/
|
|
1557
|
+
interface PeerCheckOptions {
|
|
1558
|
+
/** The workspace's effective rules, from `WorkspaceCatalogs.peerDependencyRules()`. */
|
|
1559
|
+
readonly peerDependencyRules?: PeerDependencyRules;
|
|
1560
|
+
}
|
|
1561
|
+
declare const PeerParent_base: Schema.Class<PeerParent, Schema.Struct<{
|
|
1562
|
+
/** The package name. */
|
|
1563
|
+
readonly name: Schema.NonEmptyString;
|
|
1564
|
+
/** The resolved version of that package. */
|
|
1565
|
+
readonly version: Schema.String;
|
|
1566
|
+
}>, {}>;
|
|
1567
|
+
/**
|
|
1568
|
+
* One link in the chain from an importer to the package that declared an
|
|
1569
|
+
* unsatisfied peer.
|
|
1570
|
+
*
|
|
1571
|
+
* @remarks
|
|
1572
|
+
* Mirrors the shape `pnpm peers check --json` reports, so the two can be
|
|
1573
|
+
* compared directly.
|
|
1574
|
+
*
|
|
1575
|
+
* @public
|
|
1576
|
+
*/
|
|
1577
|
+
declare class PeerParent extends PeerParent_base {}
|
|
1578
|
+
declare const UnsatisfiedPeer_base: Schema.Class<UnsatisfiedPeer, Schema.Struct<{
|
|
1579
|
+
/** The importer path the problem belongs to (`"."` for the root). */
|
|
1580
|
+
readonly importer: Schema.NonEmptyString;
|
|
1581
|
+
/** The peer dependency's name. */
|
|
1582
|
+
readonly dependency: Schema.NonEmptyString;
|
|
1583
|
+
/** The range the declaring package asked for. */
|
|
1584
|
+
readonly wanted: Schema.String;
|
|
1585
|
+
/** The version that resolved, or `null` when nothing resolved. */
|
|
1586
|
+
readonly found: Schema.NullOr<Schema.String>;
|
|
1587
|
+
/** Whether the declaring package marked the peer optional. */
|
|
1588
|
+
readonly optional: Schema.Boolean;
|
|
1589
|
+
/** The path from the importer to the declaring package. */
|
|
1590
|
+
readonly parents: Schema.$Array<typeof PeerParent>;
|
|
1591
|
+
}>, {}>;
|
|
1592
|
+
/**
|
|
1593
|
+
* One peer dependency that is declared but not satisfied.
|
|
1594
|
+
*
|
|
1595
|
+
* @remarks
|
|
1596
|
+
* `found` carries the version that actually resolved, or `null` when nothing
|
|
1597
|
+
* resolved at all. There is deliberately **no separate discriminant** between
|
|
1598
|
+
* "missing" and "unmet": `found === null` is the whole distinction, and a
|
|
1599
|
+
* renderer reads it directly.
|
|
1600
|
+
*
|
|
1601
|
+
* `optional` must be respected by any gate — an unsatisfied *optional* peer is
|
|
1602
|
+
* normal and should not fail a build, which is why the flag travels with the
|
|
1603
|
+
* row rather than being filtered out here. {@link PeerCheck.required} is the
|
|
1604
|
+
* convenience for the common case.
|
|
1605
|
+
*
|
|
1606
|
+
* `parents` is the path from the importer down to the package that declared
|
|
1607
|
+
* the peer, nearest-to-the-importer first. It is a path rather than a single
|
|
1608
|
+
* package because a peer declared by a transitive dependency is still that
|
|
1609
|
+
* importer's problem, and the chain is what makes the report actionable.
|
|
1610
|
+
*
|
|
1611
|
+
* A package an importer reaches by more than one path yields **one** row, not
|
|
1612
|
+
* one per path, carrying the path the walk reached first — which is what
|
|
1613
|
+
* `pnpm peers check` reports for the same graph. Read `parents` as *a* route to
|
|
1614
|
+
* the declaring package, never as the complete set of them.
|
|
1615
|
+
*
|
|
1616
|
+
* @public
|
|
1617
|
+
*/
|
|
1618
|
+
declare class UnsatisfiedPeer extends UnsatisfiedPeer_base {}
|
|
1619
|
+
declare const PeerCheck_base: Schema.Class<PeerCheck, Schema.Struct<{
|
|
1620
|
+
/**
|
|
1621
|
+
* Whether the lockfile's format records peer resolution at all.
|
|
1622
|
+
*
|
|
1623
|
+
* `false` for yarn: yarn resolves peers **virtually**, giving a
|
|
1624
|
+
* peer-bearing package one `@virtual:` locator per consumer, and the
|
|
1625
|
+
* lockfile does not record which virtual instance satisfied which peer.
|
|
1626
|
+
* The answer is not recoverable, so it is not fabricated — `unsatisfied`
|
|
1627
|
+
* is empty and this flag says why.
|
|
1628
|
+
*/
|
|
1629
|
+
readonly supported: Schema.Boolean;
|
|
1630
|
+
/** Every unsatisfied peer found, optional ones included. */
|
|
1631
|
+
readonly unsatisfied: Schema.$Array<typeof UnsatisfiedPeer>;
|
|
1632
|
+
/**
|
|
1633
|
+
* Importers whose dependencies could not be resolved to instances, so no
|
|
1634
|
+
* verdict was reached for them.
|
|
1635
|
+
*
|
|
1636
|
+
* @remarks
|
|
1637
|
+
* In practice this is the **root importer under npm and bun**: neither
|
|
1638
|
+
* records a resolved version per importer dependency, and neither emits a
|
|
1639
|
+
* package row for the root, so there is nothing to join on. pnpm records a
|
|
1640
|
+
* version per importer dependency and is unaffected.
|
|
1641
|
+
*
|
|
1642
|
+
* Reported rather than silently skipped, for the same reason `supported`
|
|
1643
|
+
* exists: a gate that sees no rows is entitled to know whether that means
|
|
1644
|
+
* "clean" or "not looked at".
|
|
1645
|
+
*/
|
|
1646
|
+
readonly unresolvedImporters: Schema.$Array<Schema.String>;
|
|
1647
|
+
/**
|
|
1648
|
+
* Why this report is not a complete answer, or empty when it is.
|
|
1649
|
+
*
|
|
1650
|
+
* @remarks
|
|
1651
|
+
* See {@link UnverifiedReason}. A gate must treat a non-empty `unverified`
|
|
1652
|
+
* as **not proven clean** rather than as a pass: both reasons mean some
|
|
1653
|
+
* finding may be missing or spurious, and failing closed is the requirement.
|
|
1654
|
+
*/
|
|
1655
|
+
readonly unverified: Schema.$Array<Schema.Literals<readonly ["peerRulesNotApplied", "unresolvedEdge"]>>;
|
|
1656
|
+
}>, {}>;
|
|
1657
|
+
/**
|
|
1658
|
+
* The result of checking a lockfile for unsatisfied peer dependencies.
|
|
1659
|
+
*
|
|
1660
|
+
* @remarks
|
|
1661
|
+
* A report rather than a bare array, because an empty array is the most
|
|
1662
|
+
* dangerous success shape this domain has: it is indistinguishable from "this
|
|
1663
|
+
* format cannot be checked". `supported` and `unresolvedImporters` make the
|
|
1664
|
+
* difference legible, so a consumer cannot read a limitation as a clean bill
|
|
1665
|
+
* of health.
|
|
1666
|
+
*
|
|
1667
|
+
* Pure and total — construct it with {@link PeerCheck.run}, which never fails.
|
|
1668
|
+
*
|
|
1669
|
+
* @example
|
|
1670
|
+
* ```ts
|
|
1671
|
+
* import { PeerCheck } from "@effected/workspaces";
|
|
1672
|
+
* import { Lockfile } from "@effected/lockfiles";
|
|
1673
|
+
* import { Effect } from "effect";
|
|
1674
|
+
*
|
|
1675
|
+
* const program = Effect.gen(function* () {
|
|
1676
|
+
* const lockfile = yield* Lockfile.parse(text, { format: "pnpm" });
|
|
1677
|
+
* const report = PeerCheck.run(lockfile);
|
|
1678
|
+
* return report.supported ? report.required : [];
|
|
1679
|
+
* });
|
|
1680
|
+
* ```
|
|
1681
|
+
*
|
|
1682
|
+
* @public
|
|
1683
|
+
*/
|
|
1684
|
+
declare class PeerCheck extends PeerCheck_base {
|
|
1685
|
+
/** The unsatisfied peers a gate should act on — the non-optional ones. */
|
|
1686
|
+
get required(): ReadonlyArray<UnsatisfiedPeer>;
|
|
1687
|
+
/**
|
|
1688
|
+
* Compute the unsatisfied peer dependencies of a parsed lockfile.
|
|
1689
|
+
*
|
|
1690
|
+
* @remarks
|
|
1691
|
+
* Pure, total and format-free: no IO, no error channel, and no knowledge of
|
|
1692
|
+
* which package manager wrote the file. Range satisfaction is
|
|
1693
|
+
* `@effected/semver`'s, never hand-rolled.
|
|
1694
|
+
*
|
|
1695
|
+
* The walk starts at each importer's resolved dependencies and follows
|
|
1696
|
+
* `resolved` edges, so a peer declared by a *transitive* dependency is
|
|
1697
|
+
* attributed to the importer that pulls it in, with the chain in
|
|
1698
|
+
* `parents` — matching how `pnpm peers check` attributes them.
|
|
1699
|
+
*
|
|
1700
|
+
* Three judgements are deliberate:
|
|
1701
|
+
*
|
|
1702
|
+
* - **A required peer with nothing resolved is reported** (`found: null`),
|
|
1703
|
+
* whether or not the declared range is parseable — a peer with no
|
|
1704
|
+
* provider is a fact about the graph that needs no range arithmetic.
|
|
1705
|
+
* - **An ABSENT optional peer is not reported at all**, because that is
|
|
1706
|
+
* what optional means. An optional peer resolved at the *wrong* version
|
|
1707
|
+
* still is, carrying `optional: true`. Both halves match
|
|
1708
|
+
* `pnpm peers check`, whose `missing` section contains only required
|
|
1709
|
+
* peers while its `bad` section carries optional ones.
|
|
1710
|
+
* - **An unparseable range or version with something resolved is skipped.**
|
|
1711
|
+
* The check cannot judge it, and asserting "unsatisfied" on a comparison
|
|
1712
|
+
* that was never performed would be a wrong answer rather than a gap.
|
|
1713
|
+
* - **A workspace-linked provider counts.** Resolution is followed through
|
|
1714
|
+
* whatever the edge names, including a workspace row.
|
|
1715
|
+
*
|
|
1716
|
+
* Known limits it does not paper over: yarn (see `supported`), the npm and
|
|
1717
|
+
* bun root importer (see `unresolvedImporters`), and pnpm recording no peer
|
|
1718
|
+
* declarations for workspace projects themselves — under pnpm a workspace
|
|
1719
|
+
* package's *own* unsatisfied peers are not in the lockfile at all, and
|
|
1720
|
+
* `pnpm peers check` does not report them either.
|
|
1721
|
+
*
|
|
1722
|
+
* @param lockfile - a lockfile parsed by `@effected/lockfiles`
|
|
1723
|
+
* @returns the report; never fails
|
|
1724
|
+
*/
|
|
1725
|
+
static run(lockfile: Lockfile, options?: PeerCheckOptions): PeerCheck;
|
|
1726
|
+
}
|
|
1727
|
+
//#endregion
|
|
1447
1728
|
//#region src/Publishability.d.ts
|
|
1448
1729
|
declare const PublishTarget_base: Schema.Class<PublishTarget, Schema.Struct<{
|
|
1449
1730
|
/** The package name being published. */
|
|
@@ -1479,7 +1760,16 @@ declare class PublishTarget extends PublishTarget_base {}
|
|
|
1479
1760
|
* @public
|
|
1480
1761
|
*/
|
|
1481
1762
|
interface PublishabilityDetectorShape {
|
|
1482
|
-
/**
|
|
1763
|
+
/**
|
|
1764
|
+
* The publish targets for a package; empty means it does not publish.
|
|
1765
|
+
*
|
|
1766
|
+
* @remarks
|
|
1767
|
+
* `VersioningStrategy.detect` probes a whole workspace by invoking this
|
|
1768
|
+
* concurrently — up to ten packages in flight at once, in no guaranteed
|
|
1769
|
+
* order. An overriding implementation backed by shared mutable state or a
|
|
1770
|
+
* rate-limited client must tolerate that interleaving itself; the caller
|
|
1771
|
+
* does not serialize on its behalf.
|
|
1772
|
+
*/
|
|
1483
1773
|
readonly detect: (pkg: WorkspacePackage) => Effect.Effect<ReadonlyArray<PublishTarget>>;
|
|
1484
1774
|
}
|
|
1485
1775
|
declare const PublishabilityDetector_base: Context.ServiceClass<PublishabilityDetector, "@effected/workspaces/PublishabilityDetector", PublishabilityDetectorShape>;
|
|
@@ -2126,6 +2416,26 @@ interface WorkspaceCatalogsShape {
|
|
|
2126
2416
|
* workspace) has no release-age keys, so the gate is the inert zero gate.
|
|
2127
2417
|
*/
|
|
2128
2418
|
readonly releaseAgeGate: () => Effect.Effect<ReleaseAgeGate, CatalogAssemblyFailure>;
|
|
2419
|
+
/**
|
|
2420
|
+
* The workspace's **effective** `peerDependencyRules` — pnpm's post-hoc
|
|
2421
|
+
* suppression policy, which the lockfile does not record at all.
|
|
2422
|
+
*
|
|
2423
|
+
* @remarks
|
|
2424
|
+
* Assembled from the same single read and hook replay as `set`, and memoized
|
|
2425
|
+
* with it. The `pnpm-workspace.yaml` block (what `pnpm:export` materializes)
|
|
2426
|
+
* is **seeded** into the replayed config and the config-dependency hooks
|
|
2427
|
+
* merge onto it, so one object carries both sources — the kit never merges
|
|
2428
|
+
* them itself, because that would be a second implementation of a rule the
|
|
2429
|
+
* hook seam already enforces.
|
|
2430
|
+
*
|
|
2431
|
+
* A consumer needs these to avoid **reporting** what pnpm suppresses: pnpm
|
|
2432
|
+
* computes the same peer violations and then hides the ones a rule allows,
|
|
2433
|
+
* so a checker without them reports findings pnpm calls clean.
|
|
2434
|
+
*
|
|
2435
|
+
* `ignoreMissing` and `allowAny` are carried but unmeasured — no kit code
|
|
2436
|
+
* acts on them today.
|
|
2437
|
+
*/
|
|
2438
|
+
readonly peerDependencyRules: () => Effect.Effect<PeerDependencyRules, CatalogAssemblyFailure>;
|
|
2129
2439
|
/**
|
|
2130
2440
|
* Each importer's dependency-name → resolved-version map, as the manager's
|
|
2131
2441
|
* lockfile records it. Read from the same single lockfile read as `set`, and
|
|
@@ -3072,5 +3382,5 @@ interface GetWorkspacePackagesSyncOptions extends WorkspacesSyncOptions {
|
|
|
3072
3382
|
*/
|
|
3073
3383
|
declare const getWorkspacePackagesSync: (root: string, options: GetWorkspacePackagesSyncOptions) => ReadonlyArray<WorkspacePackage>;
|
|
3074
3384
|
//#endregion
|
|
3075
|
-
export { type CatalogAssemblyFailure, CatalogSet, ChangeDetectionError, type ChangeDetectionFailure, ChangeDetectionOptions, ChangeDetector, type ChangeDetectorShape, type ClassifyOptions, ConfigDependencyHooks, type ConfigDependencyHooksShape, CyclicDependencyError, type DependencyDiff, DependencyGraph, DetectedPackageManager, type FindWorkspaceRootOptions, type GetWorkspacePackagesSyncOptions, type HookInjection, type ImporterVersions, LockfileReadError, type LockfileReadFailure, LockfileReader, type LockfileReaderOptions, type LockfileReaderShape, PackageManagerDetectionError, type PackageManagerDetectionFailure, PackageManagerDetector, type PackageManagerDetectorShape, PackageManagerEvidence, PackageManagerName, PackageNotFoundError, type PackageRelease, PackageStateSnapshot, PublishConfig, PublishTarget, PublishabilityDetector, type PublishabilityDetectorShape, ReleaseTag, type SyncFileSystem, type SyncPath, type TagClassification, type TagFormatOptions, TagStyle, TrackingTag, type TrackingTagOptions, type VersioningDetectOptions, VersioningStrategy, VersioningStrategyType, WORKSPACE_MARKERS, WorkspaceCatalogs, type WorkspaceCatalogsOptions, type WorkspaceCatalogsShape, WorkspaceDiscovery, WorkspaceDiscoveryError, type WorkspaceDiscoveryFailure, type WorkspaceDiscoveryOptions, type WorkspaceDiscoveryShape, WorkspaceInfo, type WorkspaceLookupFailure, WorkspaceManifestError, WorkspacePackage, WorkspacePatternError, WorkspaceRoot, WorkspaceRootNotFoundError, type WorkspaceRootShape, type WorkspaceSnapshotAtFailure, type WorkspaceSnapshotWorktreeFailure, WorkspaceSnapshots, type WorkspaceSnapshotsOptions, type WorkspaceSnapshotsShape, WorkspaceStateSnapshot, Workspaces, type WorkspacesOptions, type WorkspacesServices, type WorkspacesSyncOptions, classifyTag, findWorkspaceRootSync, getWorkspacePackagesSync };
|
|
3385
|
+
export { type CatalogAssemblyFailure, CatalogSet, ChangeDetectionError, type ChangeDetectionFailure, ChangeDetectionOptions, ChangeDetector, type ChangeDetectorShape, type ClassifyOptions, ConfigDependencyHooks, type ConfigDependencyHooksShape, CyclicDependencyError, type DependencyDiff, DependencyGraph, DetectedPackageManager, type FindWorkspaceRootOptions, type GetWorkspacePackagesSyncOptions, type HookInjection, type ImporterVersions, LockfileReadError, type LockfileReadFailure, LockfileReader, type LockfileReaderOptions, type LockfileReaderShape, NoPeerDependencyRules, PackageManagerDetectionError, type PackageManagerDetectionFailure, PackageManagerDetector, type PackageManagerDetectorShape, PackageManagerEvidence, PackageManagerName, PackageNotFoundError, type PackageRelease, PackageStateSnapshot, PeerCheck, type PeerCheckOptions, type PeerDependencyRules, PeerParent, PublishConfig, PublishTarget, PublishabilityDetector, type PublishabilityDetectorShape, ReleaseTag, type SyncFileSystem, type SyncPath, type TagClassification, type TagFormatOptions, TagStyle, TrackingTag, type TrackingTagOptions, UnsatisfiedPeer, type UnverifiedReason, type VersioningDetectOptions, VersioningStrategy, VersioningStrategyType, WORKSPACE_MARKERS, WorkspaceCatalogs, type WorkspaceCatalogsOptions, type WorkspaceCatalogsShape, WorkspaceDiscovery, WorkspaceDiscoveryError, type WorkspaceDiscoveryFailure, type WorkspaceDiscoveryOptions, type WorkspaceDiscoveryShape, WorkspaceInfo, type WorkspaceLookupFailure, WorkspaceManifestError, WorkspacePackage, WorkspacePatternError, WorkspaceRoot, WorkspaceRootNotFoundError, type WorkspaceRootShape, type WorkspaceSnapshotAtFailure, type WorkspaceSnapshotWorktreeFailure, WorkspaceSnapshots, type WorkspaceSnapshotsOptions, type WorkspaceSnapshotsShape, WorkspaceStateSnapshot, Workspaces, type WorkspacesOptions, type WorkspacesServices, type WorkspacesSyncOptions, classifyTag, findWorkspaceRootSync, getWorkspacePackagesSync };
|
|
3076
3386
|
//# sourceMappingURL=index.d.ts.map
|
package/index.js
CHANGED
|
@@ -3,9 +3,10 @@ import { WORKSPACE_MARKERS, WorkspaceRoot, WorkspaceRootNotFoundError } from "./
|
|
|
3
3
|
import { PackageNotFoundError, WorkspaceDiscovery, WorkspaceDiscoveryError, WorkspaceInfo, WorkspacePatternError } from "./WorkspaceDiscovery.js";
|
|
4
4
|
import { CyclicDependencyError, DependencyGraph } from "./DependencyGraph.js";
|
|
5
5
|
import { ChangeDetectionError, ChangeDetectionOptions, ChangeDetector } from "./ChangeDetector.js";
|
|
6
|
-
import { ConfigDependencyHooks } from "./ConfigDependencyHooks.js";
|
|
6
|
+
import { ConfigDependencyHooks, NoPeerDependencyRules } from "./ConfigDependencyHooks.js";
|
|
7
7
|
import { DetectedPackageManager, PackageManagerDetectionError, PackageManagerDetector, PackageManagerEvidence, PackageManagerName } from "./PackageManagerName.js";
|
|
8
8
|
import { LockfileReadError, LockfileReader } from "./LockfileReader.js";
|
|
9
|
+
import { PeerCheck, PeerParent, UnsatisfiedPeer } from "./PeerCheck.js";
|
|
9
10
|
import { PublishTarget, PublishabilityDetector } from "./Publishability.js";
|
|
10
11
|
import { ReleaseTag, TagStyle, TrackingTag, classifyTag } from "./ReleaseTag.js";
|
|
11
12
|
import { VersioningStrategy, VersioningStrategyType } from "./VersioningStrategy.js";
|
|
@@ -15,4 +16,4 @@ import { WorkspaceSnapshots } from "./WorkspaceSnapshots.js";
|
|
|
15
16
|
import { Workspaces } from "./Workspaces.js";
|
|
16
17
|
import { findWorkspaceRootSync, getWorkspacePackagesSync } from "./WorkspacesSync.js";
|
|
17
18
|
|
|
18
|
-
export { CatalogSet, ChangeDetectionError, ChangeDetectionOptions, ChangeDetector, ConfigDependencyHooks, CyclicDependencyError, DependencyGraph, DetectedPackageManager, LockfileReadError, LockfileReader, PackageManagerDetectionError, PackageManagerDetector, PackageManagerEvidence, PackageManagerName, PackageNotFoundError, PackageStateSnapshot, PublishConfig, PublishTarget, PublishabilityDetector, ReleaseTag, TagStyle, TrackingTag, VersioningStrategy, VersioningStrategyType, WORKSPACE_MARKERS, WorkspaceCatalogs, WorkspaceDiscovery, WorkspaceDiscoveryError, WorkspaceInfo, WorkspaceManifestError, WorkspacePackage, WorkspacePatternError, WorkspaceRoot, WorkspaceRootNotFoundError, WorkspaceSnapshots, WorkspaceStateSnapshot, Workspaces, classifyTag, findWorkspaceRootSync, getWorkspacePackagesSync };
|
|
19
|
+
export { CatalogSet, ChangeDetectionError, ChangeDetectionOptions, ChangeDetector, ConfigDependencyHooks, CyclicDependencyError, DependencyGraph, DetectedPackageManager, LockfileReadError, LockfileReader, NoPeerDependencyRules, PackageManagerDetectionError, PackageManagerDetector, PackageManagerEvidence, PackageManagerName, PackageNotFoundError, PackageStateSnapshot, PeerCheck, PeerParent, PublishConfig, PublishTarget, PublishabilityDetector, ReleaseTag, TagStyle, TrackingTag, UnsatisfiedPeer, VersioningStrategy, VersioningStrategyType, WORKSPACE_MARKERS, WorkspaceCatalogs, WorkspaceDiscovery, WorkspaceDiscoveryError, WorkspaceInfo, WorkspaceManifestError, WorkspacePackage, WorkspacePatternError, WorkspaceRoot, WorkspaceRootNotFoundError, WorkspaceSnapshots, WorkspaceStateSnapshot, Workspaces, classifyTag, findWorkspaceRootSync, getWorkspacePackagesSync };
|
package/internal/enumerate.js
CHANGED
|
@@ -18,6 +18,8 @@ const enumerate = (root, globs, options) => Effect.gen(function* () {
|
|
|
18
18
|
if (!isValidMaxDepth(maxDepth)) return yield* Effect.die(/* @__PURE__ */ new Error(`enumerate: ${badMaxDepthMessage(maxDepth)}`));
|
|
19
19
|
const isPackage = (absolute) => fs.exists(path.join(absolute, "package.json")).pipe(Effect.orElseSucceed(() => false));
|
|
20
20
|
const isDirectory = (absolute) => fs.stat(absolute).pipe(Effect.map((info) => info.type === "Directory"), Effect.orElseSucceed(() => false));
|
|
21
|
+
const excludes = globs.excludes;
|
|
22
|
+
const isExcluded = excludes.length === 0 ? (_) => false : (relative) => excludes.some((exclude) => exclude.matches(relative));
|
|
21
23
|
const included = /* @__PURE__ */ new Map();
|
|
22
24
|
/** A shared-traversal stop, materialized as this module's failure record. */
|
|
23
25
|
const failureOf = (stop, pattern) => ({
|
|
@@ -26,6 +28,7 @@ const enumerate = (root, globs, options) => Effect.gen(function* () {
|
|
|
26
28
|
detail: stop.detail
|
|
27
29
|
});
|
|
28
30
|
for (const literal of globs.literals) {
|
|
31
|
+
if (isExcluded(literal)) continue;
|
|
29
32
|
const absolute = path.join(root, literal);
|
|
30
33
|
if (yield* isPackage(absolute)) included.set(literal, absolute);
|
|
31
34
|
}
|
|
@@ -53,20 +56,17 @@ const enumerate = (root, globs, options) => Effect.gen(function* () {
|
|
|
53
56
|
const absolute = path.join(frame.absolute, entry);
|
|
54
57
|
if (!(yield* isDirectory(absolute))) continue;
|
|
55
58
|
if (wildcard.crossesSegments && !traversal.admits(frame)) return yield* Effect.fail(failureOf(traversal.depthStop(), wildcard.source));
|
|
56
|
-
if (wildcard.matches(relative) && (yield* isPackage(absolute))) included.set(relative, absolute);
|
|
59
|
+
if (wildcard.matches(relative) && !isExcluded(relative) && (yield* isPackage(absolute))) included.set(relative, absolute);
|
|
57
60
|
if (!wildcard.crossesSegments) continue;
|
|
58
61
|
traversal.push(frame, relative, absolute);
|
|
59
62
|
}
|
|
60
63
|
}
|
|
61
64
|
}
|
|
62
65
|
const results = [];
|
|
63
|
-
for (const [relativePath, absolute] of included) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
path: absolute
|
|
68
|
-
});
|
|
69
|
-
}
|
|
66
|
+
for (const [relativePath, absolute] of included) results.push({
|
|
67
|
+
relativePath,
|
|
68
|
+
path: absolute
|
|
69
|
+
});
|
|
70
70
|
results.sort((a, b) => a.relativePath < b.relativePath ? -1 : a.relativePath > b.relativePath ? 1 : 0);
|
|
71
71
|
return results;
|
|
72
72
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/workspaces",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Monorepo workspace tooling as Effect services — root discovery, package enumeration, the dependency graph, package-manager detection, pnpm catalog resolution, lockfile IO and git-based change detection.",
|
|
6
6
|
"keywords": [
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"@effected/commands": "^0.5.0",
|
|
50
50
|
"@effected/git": "^0.9.0",
|
|
51
51
|
"@effected/glob": "^0.4.0",
|
|
52
|
-
"@effected/lockfiles": "^0.
|
|
52
|
+
"@effected/lockfiles": "^0.6.1",
|
|
53
53
|
"@effected/npm": "^0.11.0",
|
|
54
54
|
"@effected/package-json": "^0.10.2",
|
|
55
55
|
"@effected/semver": "^0.5.0",
|