@vltpkg/workspaces 1.2.0 → 1.3.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/README.md CHANGED
@@ -79,6 +79,24 @@ type VltProject = {
79
79
  }
80
80
  ```
81
81
 
82
+ If `vlt.json` has no `workspaces` field, the npm/yarn-style
83
+ `workspaces` field of the project root's `package.json` is used
84
+ instead, so an existing npm or yarn monorepo works without a
85
+ `vlt.json`. Only the shapes npm and yarn understand are accepted there
86
+ -- a glob string, an array of them, or `{packages: [...]}` (plus
87
+ yarn-classic's `nohoist`, which is parsed and ignored). Named groups
88
+ are rejected in `package.json`, because a `package.json` using them
89
+ would be meaningless to every other package manager.
90
+
91
+ Note the precedence is keyed on the _field_, not the file: a
92
+ `vlt.json` that exists but says nothing about workspaces still falls
93
+ through to `package.json`. The two are never merged.
94
+
95
+ In either file, a pattern beginning with `!` excludes the paths it
96
+ matches, following npm's semantics: `!a/b` excludes only that
97
+ directory, `!a/b/**` excludes the subtree, and a later positive
98
+ pattern overrides an earlier exclusion.
99
+
82
100
  If it's an object, each key is a group name, and each value is a path,
83
101
  glob, or array of paths and globs, which specify the location of the
84
102
  workspace projects. Glob matches are only considered if they are a
package/dist/index.d.ts CHANGED
@@ -41,6 +41,54 @@ export declare const asWSConfig: (conf: unknown, path?: string) => WorkspaceConf
41
41
  * Throw if the provided value is not a valid {@link WorkspaceConfig}
42
42
  */
43
43
  export declare const assertWSConfig: (conf: unknown, path?: string) => asserts conf is WorkspaceConfig;
44
+ /**
45
+ * Turn the `workspaces` field of a root `package.json` into a
46
+ * {@link WorkspaceConfigObject}.
47
+ *
48
+ * Only the shapes npm and yarn-classic actually understand are
49
+ * accepted: a glob string, an array of them, or `{packages: [...]}`.
50
+ * vlt's named workspace groups are deliberately *not* accepted here --
51
+ * a `package.json` using them would be meaningless to every other
52
+ * package manager, so they have to live in `vlt.json`.
53
+ */
54
+ export declare const asManifestWSConfig: (conf: unknown, path: string) => WorkspaceConfigObject;
55
+ /**
56
+ * Which file a project's workspace definitions came from.
57
+ */
58
+ export type WorkspaceConfigSource = 'vlt.json' | 'package.json';
59
+ export type ResolvedWorkspaceConfig = {
60
+ config: WorkspaceConfigObject;
61
+ /**
62
+ * The file the config came from, or `undefined` when neither
63
+ * `vlt.json` nor the root `package.json` declares any workspaces at
64
+ * all -- ie, when the project is not a monorepo.
65
+ */
66
+ source?: WorkspaceConfigSource;
67
+ };
68
+ /**
69
+ * Resolve the effective workspace definitions for a project.
70
+ *
71
+ * `vlt.json` wins whenever it has a `workspaces` field; otherwise the
72
+ * npm/yarn-style `workspaces` field of the project root's
73
+ * `package.json` is used. Note the precedence is keyed on the *field*,
74
+ * not the file: a `vlt.json` that exists but says nothing about
75
+ * workspaces still falls through to `package.json`.
76
+ */
77
+ export declare const resolveWSConfig: (projectRoot: string, packageJson?: PackageJson) => ResolvedWorkspaceConfig;
78
+ /**
79
+ * Split a list of glob patterns into the positive patterns and the
80
+ * npm-compatible `!`-negated ones, which become ignore patterns.
81
+ *
82
+ * Follows npm's `map-workspaces` rules: an even number of leading `!`
83
+ * is a positive pattern (`!!foo` means `foo`), a leading `./` or `/` is
84
+ * stripped, and a later positive pattern un-negates any earlier
85
+ * negation it matches, so `['a/**', '!a/b/**', 'a/b/c']` keeps
86
+ * `a/b/c`.
87
+ */
88
+ export declare const splitNegatedPatterns: (all: string[]) => {
89
+ patterns: string[];
90
+ ignore: string[];
91
+ };
44
92
  export type MonorepoOptions = {
45
93
  /**
46
94
  * A {@link PackageJson} object, for sharing manifest caches
@@ -51,8 +99,9 @@ export type MonorepoOptions = {
51
99
  */
52
100
  scurry?: PathScurry;
53
101
  /**
54
- * Parsed normalized contents of the workspaces from a `vlt.json`
55
- * file
102
+ * Parsed normalized contents of the workspaces, from either a
103
+ * `vlt.json` or a `package.json` file. If set, the file is not read
104
+ * again.
56
105
  */
57
106
  config?: WorkspaceConfigObject;
58
107
  /**
@@ -81,9 +130,9 @@ export declare class Monorepo {
81
130
  get size(): number;
82
131
  constructor(projectRoot: string, options?: MonorepoOptions);
83
132
  /**
84
- * Load the workspace definitions from vlt.json,
85
- * canonicalizing the result into the effective `{[group:string]:string[]}`
86
- * form.
133
+ * Load the workspace definitions from vlt.json, or from the root
134
+ * package.json when vlt.json declares none, canonicalizing the result
135
+ * into the effective `{[group:string]:string[]}` form.
87
136
  *
88
137
  * Eg:
89
138
  * - `"src/*"` => `{packages:["src/*"]}`
@@ -124,6 +173,12 @@ export declare class Monorepo {
124
173
  * This does *not* get the full set of dependencies, or expand any
125
174
  * `workspace:` dependencies that are not loaded.
126
175
  *
176
+ * Bare semver specs are matched by name, and linked only when the
177
+ * local workspace version satisfies the range -- the same test
178
+ * `@vltpkg/satisfies` applies at resolution time, so the two agree on
179
+ * which deps are local. Specs that aren't parseable ranges, such as
180
+ * dist-tags, still match by name alone.
181
+ *
127
182
  * Call with the `forceLoad` param set to `true` to attempt a full
128
183
  * load if any deps are not currently loaded.
129
184
  */
@@ -193,8 +248,9 @@ export declare class Monorepo {
193
248
  runSync<R>(operation: (s: Workspace, signal: AbortSignal, depResults: DepResults<Workspace, R>) => R, forceLoad?: boolean): Map<Workspace, R>;
194
249
  /**
195
250
  * Convenience method to instantiate and load in one call.
196
- * Returns undefined if the project is not a monorepo workspaces
197
- * root, otherwise returns the loaded Monorepo.
251
+ * Returns undefined if the project is not a monorepo workspaces root,
252
+ * meaning neither `vlt.json` nor the root `package.json` declares any
253
+ * workspaces. Otherwise returns the loaded Monorepo.
198
254
  */
199
255
  static maybeLoad(projectRoot: string, options?: MonorepoOptions): Monorepo | undefined;
200
256
  /**
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { joinDepIDTuple } from '@vltpkg/dep-id';
2
2
  import { error } from '@vltpkg/error-cause';
3
3
  import { PackageJson } from '@vltpkg/package-json';
4
+ import { parseRange, satisfies } from '@vltpkg/semver';
4
5
  import { load } from '@vltpkg/vlt-json';
5
- import { globSync } from 'glob';
6
+ import { globSync, Ignore } from 'glob';
6
7
  import { graphRun, graphRunSync } from '@vltpkg/graph-run';
7
8
  import { minimatch } from 'minimatch';
8
9
  import { basename, posix, resolve } from 'node:path';
@@ -89,6 +90,92 @@ export const assertWSConfig = (conf, path) => {
89
90
  wanted: 'string | string[] | { [group: string]: string | string[] }',
90
91
  });
91
92
  };
93
+ /**
94
+ * Keys that yarn-classic allows alongside `packages` in the
95
+ * `workspaces` field of a `package.json`, which vlt parses and ignores
96
+ * rather than rejecting an otherwise valid yarn v1 project.
97
+ */
98
+ const manifestWSIgnoredKeys = new Set(['nohoist']);
99
+ /**
100
+ * Turn the `workspaces` field of a root `package.json` into a
101
+ * {@link WorkspaceConfigObject}.
102
+ *
103
+ * Only the shapes npm and yarn-classic actually understand are
104
+ * accepted: a glob string, an array of them, or `{packages: [...]}`.
105
+ * vlt's named workspace groups are deliberately *not* accepted here --
106
+ * a `package.json` using them would be meaningless to every other
107
+ * package manager, so they have to live in `vlt.json`.
108
+ */
109
+ export const asManifestWSConfig = (conf, path) => {
110
+ if (conf && typeof conf === 'object' && !Array.isArray(conf)) {
111
+ const groups = Object.keys(conf).filter(k => k !== 'packages' && !manifestWSIgnoredKeys.has(k));
112
+ if (groups.length) {
113
+ throw error('Named workspace groups are not supported in package.json. ' +
114
+ 'Move them to the "workspaces" field of vlt.json to use them.', {
115
+ path,
116
+ found: groups,
117
+ wanted: 'string | string[] | { packages: string[] }',
118
+ });
119
+ }
120
+ const { packages } = conf;
121
+ if (packages === undefined)
122
+ return {};
123
+ return asWSConfig({ packages }, path);
124
+ }
125
+ return asWSConfig(conf, path);
126
+ };
127
+ /**
128
+ * Resolve the effective workspace definitions for a project.
129
+ *
130
+ * `vlt.json` wins whenever it has a `workspaces` field; otherwise the
131
+ * npm/yarn-style `workspaces` field of the project root's
132
+ * `package.json` is used. Note the precedence is keyed on the *field*,
133
+ * not the file: a `vlt.json` that exists but says nothing about
134
+ * workspaces still falls through to `package.json`.
135
+ */
136
+ export const resolveWSConfig = (projectRoot, packageJson = new PackageJson()) => {
137
+ const fromVltJson = load('workspaces', assertWSConfig);
138
+ if (fromVltJson !== undefined) {
139
+ return { config: asWSConfig(fromVltJson), source: 'vlt.json' };
140
+ }
141
+ // maybeRead, because a project root need not have a package.json at
142
+ // all, and an unreadable one is not this module's error to report.
143
+ const workspaces = packageJson.maybeRead(projectRoot)?.workspaces;
144
+ if (workspaces === undefined)
145
+ return { config: {} };
146
+ return {
147
+ config: asManifestWSConfig(workspaces, resolve(projectRoot, 'package.json')),
148
+ source: 'package.json',
149
+ };
150
+ };
151
+ /**
152
+ * Split a list of glob patterns into the positive patterns and the
153
+ * npm-compatible `!`-negated ones, which become ignore patterns.
154
+ *
155
+ * Follows npm's `map-workspaces` rules: an even number of leading `!`
156
+ * is a positive pattern (`!!foo` means `foo`), a leading `./` or `/` is
157
+ * stripped, and a later positive pattern un-negates any earlier
158
+ * negation it matches, so `['a/**', '!a/b/**', 'a/b/c']` keeps
159
+ * `a/b/c`.
160
+ */
161
+ export const splitNegatedPatterns = (all) => {
162
+ const patterns = [];
163
+ let ignore = [];
164
+ for (const orig of all) {
165
+ const excl = /^!+/.exec(orig);
166
+ const stripped = excl ? orig.slice(excl[0].length) : orig;
167
+ // `./foo` and `/foo` both just mean `foo`
168
+ const pattern = stripped.replace(/^\.?\/+/, '');
169
+ if (excl && excl[0].length % 2 === 1) {
170
+ ignore.push(pattern);
171
+ continue;
172
+ }
173
+ // a positive pattern un-negates any earlier negation matching it
174
+ ignore = ignore.filter(ign => !minimatch(pattern, ign));
175
+ patterns.push(pattern);
176
+ }
177
+ return { patterns, ignore };
178
+ };
92
179
  /**
93
180
  * Class representing a Monorepo containing multiple workspaces.
94
181
  *
@@ -121,9 +208,9 @@ export class Monorepo {
121
208
  this.load(options.load);
122
209
  }
123
210
  /**
124
- * Load the workspace definitions from vlt.json,
125
- * canonicalizing the result into the effective `{[group:string]:string[]}`
126
- * form.
211
+ * Load the workspace definitions from vlt.json, or from the root
212
+ * package.json when vlt.json declares none, canonicalizing the result
213
+ * into the effective `{[group:string]:string[]}` form.
127
214
  *
128
215
  * Eg:
129
216
  * - `"src/*"` => `{packages:["src/*"]}`
@@ -132,7 +219,7 @@ export class Monorepo {
132
219
  get config() {
133
220
  if (this.#config)
134
221
  return this.#config;
135
- this.#config = asWSConfig(load('workspaces', assertWSConfig) ?? {});
222
+ this.#config = resolveWSConfig(this.projectRoot, this.packageJson).config;
136
223
  return this.#config;
137
224
  }
138
225
  /**
@@ -236,7 +323,7 @@ export class Monorepo {
236
323
  }
237
324
  // can't be cached, because it's dependent on the matches set
238
325
  // but still worthwhile to have it defined in one place
239
- #globOptions(matches, parseErrors) {
326
+ #globOptions(matches, ignore, parseErrors) {
240
327
  // if the entry or any of its parent dirs are already matched,
241
328
  // then we should not explore further down that directory tree.
242
329
  // if we hit the projectRoot then stop searching.
@@ -252,6 +339,9 @@ export class Monorepo {
252
339
  withFileTypes: false,
253
340
  ignore: {
254
341
  childrenIgnored: p => basename(p.relativePosix()) === 'node_modules' ||
342
+ // only prunes the subtree for a `!foo/**` style negation,
343
+ // matching npm -- a bare `!foo` still gets walked into
344
+ !!ignore?.childrenIgnored(p) ||
255
345
  inMatches(p),
256
346
  // ignore if fails to load package.json
257
347
  ignored: p => {
@@ -259,6 +349,11 @@ export class Monorepo {
259
349
  const rel = p.relativePosix();
260
350
  if (!rel)
261
351
  return true;
352
+ // checked before the `matches` bookkeeping below, so a negated
353
+ // path never lands in `matches` and therefore never suppresses
354
+ // its own descendants
355
+ if (ignore?.ignored(p))
356
+ return true;
262
357
  const maybeDelete = [];
263
358
  for (const m of matches) {
264
359
  if (rel.startsWith(m + '/'))
@@ -294,10 +389,16 @@ export class Monorepo {
294
389
  },
295
390
  };
296
391
  }
392
+ // patterns are always an array: asWSConfig normalizes every
393
+ // WorkspaceConfig shape to string[], and the path filter is a Set
297
394
  #glob(pattern) {
395
+ const { patterns, ignore } = splitNegatedPatterns(pattern);
298
396
  const matches = new Set();
397
+ // nothing but negations can never match anything
398
+ if (!patterns.length)
399
+ return matches;
299
400
  const parseErrors = new Map();
300
- globSync(pattern, this.#globOptions(matches, parseErrors));
401
+ globSync(patterns, this.#globOptions(matches, ignore.length ? new Ignore(ignore, {}) : undefined, parseErrors));
301
402
  // After the glob completes, check for JSON parse errors in paths
302
403
  // that are NOT nested inside an already-matched workspace.
303
404
  // Nested directories (like app/bar/badjson inside workspace app/bar)
@@ -328,6 +429,12 @@ export class Monorepo {
328
429
  * This does *not* get the full set of dependencies, or expand any
329
430
  * `workspace:` dependencies that are not loaded.
330
431
  *
432
+ * Bare semver specs are matched by name, and linked only when the
433
+ * local workspace version satisfies the range -- the same test
434
+ * `@vltpkg/satisfies` applies at resolution time, so the two agree on
435
+ * which deps are local. Specs that aren't parseable ranges, such as
436
+ * dist-tags, still match by name alone.
437
+ *
331
438
  * Call with the `forceLoad` param set to `true` to attempt a full
332
439
  * load if any deps are not currently loaded.
333
440
  */
@@ -347,8 +454,17 @@ export class Monorepo {
347
454
  if (!deps)
348
455
  continue;
349
456
  for (const [dep, spec] of Object.entries(deps)) {
350
- if (spec.startsWith('workspace:')) {
457
+ // `workspace:` specs, plus any spec with no protocol -- bare
458
+ // semver ranges and dist-tags, which are how npm/yarn monorepos
459
+ // reference each other. Anything with a protocol (`npm:`,
460
+ // `file:`, `git:`, `catalog:`, ...) names something other than a
461
+ // local workspace, or aliases a different package entirely.
462
+ if (spec.startsWith('workspace:') || !spec.includes(':')) {
351
463
  let depWS = this.#workspaces.get(dep);
464
+ // #workspaces is keyed by name *and* path, so a path that
465
+ // happens to equal a dependency name is not a match
466
+ if (depWS && depWS.name !== dep)
467
+ depWS = undefined;
352
468
  if (!depWS) {
353
469
  if (!forceLoad)
354
470
  continue;
@@ -357,9 +473,22 @@ export class Monorepo {
357
473
  didForceLoad = true;
358
474
  this.load();
359
475
  depWS = this.#workspaces.get(dep);
360
- if (!depWS)
476
+ if (depWS?.name !== dep)
361
477
  continue;
362
478
  }
479
+ // A bare spec only refers to the local workspace when its
480
+ // version actually satisfies the range -- otherwise the dep
481
+ // resolves to the registry, and linking it here would add an
482
+ // edge that isn't real. That matters beyond ordering: paired
483
+ // with a genuine dep the other way it fabricates a cycle,
484
+ // and onCycle drops an edge to break it. Ranges we can't
485
+ // parse (dist-tags, `user/repo` shorthands) keep matching by
486
+ // name, as before.
487
+ const range = spec.includes(':') ? undefined : parseRange(spec);
488
+ if (range &&
489
+ !satisfies(depWS.manifest.version ?? '', range)) {
490
+ continue;
491
+ }
363
492
  depWorkspaces.push(depWS);
364
493
  }
365
494
  }
@@ -510,14 +639,20 @@ export class Monorepo {
510
639
  }
511
640
  /**
512
641
  * Convenience method to instantiate and load in one call.
513
- * Returns undefined if the project is not a monorepo workspaces
514
- * root, otherwise returns the loaded Monorepo.
642
+ * Returns undefined if the project is not a monorepo workspaces root,
643
+ * meaning neither `vlt.json` nor the root `package.json` declares any
644
+ * workspaces. Otherwise returns the loaded Monorepo.
515
645
  */
516
646
  static maybeLoad(projectRoot, options = { load: {} }) {
517
- const config = load('workspaces', assertWSConfig);
518
- if (!config)
647
+ if (options.config) {
648
+ return new Monorepo(projectRoot, { load: {}, ...options });
649
+ }
650
+ const { config, source } = resolveWSConfig(projectRoot, options.packageJson);
651
+ if (!source)
519
652
  return;
520
- return new Monorepo(projectRoot, { load: {}, ...options });
653
+ // hand the resolved config to the instance so the file that
654
+ // declared it isn't read a second time
655
+ return new Monorepo(projectRoot, { load: {}, ...options, config });
521
656
  }
522
657
  /**
523
658
  * Convenience method to instantiate and load in one call.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vltpkg/workspaces",
3
3
  "description": "Utility for working with vlt workspaces",
4
- "version": "1.2.0",
4
+ "version": "1.3.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/vltpkg/vltpkg.git",
@@ -13,12 +13,13 @@
13
13
  "url": "http://vlt.sh"
14
14
  },
15
15
  "dependencies": {
16
- "@vltpkg/dep-id": "1.2.0",
17
- "@vltpkg/error-cause": "1.2.0",
18
- "@vltpkg/graph-run": "1.2.0",
19
- "@vltpkg/package-json": "1.2.0",
20
- "@vltpkg/types": "1.2.0",
21
- "@vltpkg/vlt-json": "1.2.0",
16
+ "@vltpkg/dep-id": "1.3.0",
17
+ "@vltpkg/error-cause": "1.3.0",
18
+ "@vltpkg/graph-run": "1.3.0",
19
+ "@vltpkg/package-json": "1.3.0",
20
+ "@vltpkg/semver": "1.3.0",
21
+ "@vltpkg/types": "1.3.0",
22
+ "@vltpkg/vlt-json": "1.3.0",
22
23
  "glob": "^13.0.0",
23
24
  "minimatch": "^10.1.1",
24
25
  "path-scurry": "^2.0.1"