rman 2.8.0 → 2.9.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.
@@ -9,6 +9,11 @@ export declare class NodeManifestProvider implements ManifestProvider {
9
9
  * npm's four dependency fields, and only the entries naming a package of this repository -
10
10
  * an external dependency is not an edge in rman's graph.
11
11
  *
12
+ * A peer marked `"optional": true` in `peerDependenciesMeta` is **not** an edge: an optional peer
13
+ * is by definition one the package works without, so it states no order. It still counts where
14
+ * another field declares it too - the fields are read together, and `devDependencies` naming it
15
+ * is a real build-time need whatever the peer block says.
16
+ *
12
17
  * Matched by name, which is npm's own identifier and unique by construction; an ecosystem where
13
18
  * that does not hold resolves its own way, which is why this is the provider's job.
14
19
  */
@@ -51,6 +56,11 @@ export declare class NodeManifestProvider implements ManifestProvider {
51
56
  constant?: string;
52
57
  }): string | undefined;
53
58
  write(dir: string, manifest: Manifest): void;
59
+ /** The peers this manifest marks `"optional": true`, which `dependencies` leaves out of the
60
+ * graph. `optionalDependencies` is deliberately *not* read the same way: an optional peer says
61
+ * "works without it", while an optional dependency is one the package means to use and may fail
62
+ * to install. */
63
+ protected optionalPeerNames(manifest: Manifest): Set<string>;
54
64
  }
55
65
  /**
56
66
  * `package.json` as rman's manifest: where an npm package's name and version are written.
@@ -26,17 +26,34 @@ export class NodeManifestProvider {
26
26
  * npm's four dependency fields, and only the entries naming a package of this repository -
27
27
  * an external dependency is not an edge in rman's graph.
28
28
  *
29
+ * A peer marked `"optional": true` in `peerDependenciesMeta` is **not** an edge: an optional peer
30
+ * is by definition one the package works without, so it states no order. It still counts where
31
+ * another field declares it too - the fields are read together, and `devDependencies` naming it
32
+ * is a real build-time need whatever the peer block says.
33
+ *
29
34
  * Matched by name, which is npm's own identifier and unique by construction; an ecosystem where
30
35
  * that does not hold resolves its own way, which is why this is the provider's job.
31
36
  */
37
+ /* **Measured on `panates/opra`, which is why the second half of that rule is written out.**
38
+ * Seven of its edges are optional peers, and *every one* of them is also a `devDependencies`
39
+ * entry - so excluding optional peers removed no edge at all, and the `@opra/cli` <-> `@opra/api-ui`
40
+ * cycle that was destroying its build order stood untouched. Reading the peer block alone would
41
+ * have looked like a fix and changed nothing. */
32
42
  dependencies(manifest, candidates) {
33
- const declared = Object.assign({}, ...DEPENDENCY_KEYS.map(key => manifest.raw[key]));
43
+ const optionalPeers = this.optionalPeerNames(manifest);
34
44
  const byName = new Map(candidates.map(p => [p.name, p]));
35
45
  const result = [];
36
- for (const name of Object.keys(declared)) {
37
- const pkg = byName.get(name);
38
- if (pkg && !result.includes(pkg))
39
- result.push(pkg);
46
+ for (const key of DEPENDENCY_KEYS) {
47
+ const declared = manifest.raw[key];
48
+ if (!declared || typeof declared !== 'object')
49
+ continue;
50
+ for (const name of Object.keys(declared)) {
51
+ if (key === 'peerDependencies' && optionalPeers.has(name))
52
+ continue;
53
+ const pkg = byName.get(name);
54
+ if (pkg && !result.includes(pkg))
55
+ result.push(pkg);
56
+ }
40
57
  }
41
58
  return result;
42
59
  }
@@ -108,6 +125,16 @@ export class NodeManifestProvider {
108
125
  const raw = { ...manifest.raw, version: manifest.version };
109
126
  fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(raw, undefined, 2) + '\n', 'utf-8');
110
127
  }
128
+ /** The peers this manifest marks `"optional": true`, which `dependencies` leaves out of the
129
+ * graph. `optionalDependencies` is deliberately *not* read the same way: an optional peer says
130
+ * "works without it", while an optional dependency is one the package means to use and may fail
131
+ * to install. */
132
+ optionalPeerNames(manifest) {
133
+ const meta = manifest.raw.peerDependenciesMeta;
134
+ if (!meta || typeof meta !== 'object')
135
+ return new Set();
136
+ return new Set(Object.keys(meta).filter(name => meta[name]?.optional === true));
137
+ }
111
138
  }
112
139
  /**
113
140
  * `package.json` as rman's manifest: where an npm package's name and version are written.
@@ -76,9 +76,22 @@ export var CleanService;
76
76
  }
77
77
  CleanService.clean = clean;
78
78
  })(CleanService || (CleanService = {}));
79
- /** Directories a package's TypeScript output gets cleaned from, mirroring `ts-cleanup -s src`
80
- * and `-s test` (the only way it was ever actually invoked in this project's own scripts). */
81
- const TS_SOURCE_DIRS = ['src', 'test'];
79
+ /**
80
+ * Where a package's own build output lives, so the sweep below can leave it alone - the resolved
81
+ * `publish.npm.directory`, falling back to `build`.
82
+ *
83
+ * **Read from the config, never hardcoded.** The directory is the repository's to name: the shared
84
+ * preset drives it from a `vars.buildDir`, and a repository that renames it moves
85
+ * `publish.npm.directory`, `clean.include` and the build itself together. A literal `build` here
86
+ * would sweep a repository that calls it `dist` - deleting every emitted file in it, one at a time,
87
+ * because each sits beside nothing and the guard below would not fire. `publish`'s own generated
88
+ * manifest reads the same key, which is what keeps the two from disagreeing about where a package
89
+ * is built.
90
+ */
91
+ function buildDirOf(pkg) {
92
+ const configured = pkg.config?.publish?.npm?.directory;
93
+ return typeof configured === 'string' && configured ? configured : 'build';
94
+ }
82
95
  /** A package's (cascaded) `.rmanrc clean.include`/`clean.exclude`/`clean.skip` - a package that
83
96
  * declares its own `clean` block replaces the root's entirely for itself (same as any other
84
97
  * rman config key), rather than combining with it. `include`/`exclude` accept a single glob
@@ -157,32 +170,100 @@ async function cleanGlobs(dirname, include, exclude, dryRun) {
157
170
  return removed;
158
171
  }
159
172
  /**
160
- * Removes compiled TypeScript output (`.js`, `.js.map`, `.d.ts`) sitting next to its `.ts`
161
- * source under `src`/`test` - the same job `ts-cleanup -s <dir> --all` did (the only mode this
162
- * project ever actually used it in). A `.d.ts` with no matching `.ts`/`.tsx` is left alone - it's
163
- * presumably a hand-written declaration file, not build output. Prunes directories left empty
164
- * afterward.
173
+ * Removes compiled TypeScript output (`.js`, `.js.map`, `.d.ts`) sitting next to its `.ts` source,
174
+ * anywhere in the package except `node_modules` and the build directory. Prunes directories left
175
+ * empty afterward.
176
+ *
177
+ * **Two tiers, because how much a directory tells you differs.** Under `src`/`test` everything is
178
+ * TypeScript, so a compiled file is swept whether or not its source is still there - which keeps
179
+ * the orphan case working, a `.js` whose `.ts` was renamed or deleted. Anywhere else in the package
180
+ * a file is swept **only when a matching `.ts`/`.tsx` sits beside it**, because that is the only
181
+ * thing that distinguishes output from something somebody wrote.
165
182
  */
166
- async function cleanTsArtifacts(dirname, dryRun) {
183
+ /* **It used to look only in `src/` and `test/`**, inherited from `ts-cleanup -s src` - and that is
184
+ * the half this exists to fix. `tsc` writes its output beside the source whenever a config does not
185
+ * send it elsewhere, which is exactly the accident `clean` is reached for, and a package keeping
186
+ * `index.ts` at its own root was therefore never swept. Measured on `panates/opra`: ten emitted
187
+ * files in `examples/**` survived every `rman clean`, and `rman lint` there died inside
188
+ * `eslint-plugin-import-x` on one of them - a crash that looks like an eslint problem and is a stale
189
+ * artifact.
190
+ *
191
+ * **Widening the search without a guard outside `src`/`test` would have been destructive**, which
192
+ * is why the two tiers arrived together. Under `src/` a bare `.js` is deleted unconditionally and
193
+ * should be; over a whole package the same rule reaches `index.js`, `*.config.js`, `scripts/*.js` -
194
+ * hand-written files with no `.ts` behind them. The `.d.ts` rule was already the right one for that
195
+ * ground and now covers all three extensions there. The cost, stated rather than hidden: outside a
196
+ * source root, a compiled `.js` whose `.ts` has since been deleted is left alone, because nothing
197
+ * on disk says it was ever generated.
198
+ *
199
+ * **The build directory is excluded by name from the config, never as the literal `build`** - see
200
+ * `buildDirOf`. Its contents are the *point* of a build and are removed by `clean.include` when the
201
+ * repository asks, not by a rule about stray files. */
202
+ async function cleanTsArtifacts(pkg, dryRun) {
203
+ const dirname = pkg.dirname;
204
+ const buildDir = buildDirOf(pkg);
205
+ const files = await fg('**/*.{js,js.map,d.ts}', {
206
+ cwd: dirname,
207
+ ignore: ['**/node_modules/**', `${buildDir}/**`, `**/${buildDir}/**`],
208
+ onlyFiles: true,
209
+ dot: true,
210
+ absolute: true,
211
+ });
167
212
  const removed = [];
213
+ for (const f of files) {
214
+ if (!isBuildOutput(dirname, f))
215
+ continue;
216
+ await remove(f, dryRun);
217
+ removed.push(path.relative(dirname, f));
218
+ }
219
+ /** Pruned from the source roots, exactly as before - `pruneEmptyDirs` with `includeSelf: false`
220
+ * clears what is *under* a directory and keeps the directory itself, so `src/sub` left empty is
221
+ * removed and `src` is not. Nothing is pruned elsewhere in the package: a directory that was
222
+ * only searched is not one the caller asked to delete. */
168
223
  for (const sub of TS_SOURCE_DIRS) {
169
224
  const dir = path.join(dirname, sub);
170
- if (!fs.existsSync(dir))
171
- continue;
172
- const files = await fg('**/*.{js,js.map,d.ts}', { cwd: dir, onlyFiles: true, dot: true, absolute: true });
173
- for (const f of files) {
174
- if (f.endsWith('.d.ts')) {
175
- const base = f.slice(0, -'.d.ts'.length);
176
- if (!fs.existsSync(base + '.ts') && !fs.existsSync(base + '.tsx'))
177
- continue;
178
- }
179
- await remove(f, dryRun);
180
- removed.push(path.relative(dirname, f));
181
- }
182
- pruneEmptyDirs(dir, false, dryRun);
225
+ if (fs.existsSync(dir))
226
+ pruneEmptyDirs(dir, false, dryRun);
183
227
  }
184
228
  return removed;
185
229
  }
230
+ /**
231
+ * Whether a compiled file is this package's own build output, and so may go.
232
+ *
233
+ * Three rules, and each is a different amount of evidence:
234
+ *
235
+ * - **A `.d.ts` always needs its `.ts`/`.tsx` beside it**, everywhere including `src` - a
236
+ * hand-written declaration is an ordinary thing to keep in a source tree, and this is the rule
237
+ * that has always been here.
238
+ * - **A `.js`/`.js.map` under `src`/`test` goes regardless.** Everything there is TypeScript, so an
239
+ * orphan - output whose source was renamed or deleted - is still output, and sweeping it is the
240
+ * `ts-cleanup --all` behaviour this replaced.
241
+ * - **A `.js`/`.js.map` anywhere else needs its source beside it**, because out there it could just
242
+ * as easily be `index.js`, `*.config.js` or `scripts/*.js`, which nobody generated.
243
+ */
244
+ function isBuildOutput(dirname, file) {
245
+ if (file.endsWith('.d.ts'))
246
+ return hasTsSource(file, '.d.ts');
247
+ if (insideTsSourceDir(dirname, file))
248
+ return true;
249
+ return hasTsSource(file, file.endsWith('.js.map') ? '.js.map' : '.js');
250
+ }
251
+ /** Directories where everything is TypeScript, so a compiled file needs no source beside it to be
252
+ * recognized as one - the two `ts-cleanup -s <dir>` was ever invoked with here. */
253
+ const TS_SOURCE_DIRS = ['src', 'test'];
254
+ /** Whether `file` sits under one of the package's TypeScript source roots. Compared on the path
255
+ * segments rather than with `startsWith`, so a `source/` directory is not read as `src`. */
256
+ function insideTsSourceDir(dirname, file) {
257
+ const rel = path.relative(dirname, file).split(path.sep);
258
+ return TS_SOURCE_DIRS.includes(rel[0]);
259
+ }
260
+ /** Whether a compiled file has its `.ts`/`.tsx` source sitting beside it - which is what makes it
261
+ * compiled output rather than something somebody wrote. `suffix` is the compiled extension to
262
+ * strip, passed in because `.js.map` and `.js` both end in `.js` and the caller already knows. */
263
+ function hasTsSource(file, suffix) {
264
+ const base = file.slice(0, -suffix.length);
265
+ return fs.existsSync(base + '.ts') || fs.existsSync(base + '.tsx');
266
+ }
186
267
  /**
187
268
  * Removes TypeScript's incremental-build cache files (`*.tsbuildinfo`) anywhere under `dirname` -
188
269
  * `tsc --build`/`composite`/`incremental` output that `ts-cleanup` never touched, but which can
@@ -206,7 +287,7 @@ async function cleanPackage(pkg, item, panelEnabled, dryRun, logger) {
206
287
  item.startedAt = Date.now();
207
288
  try {
208
289
  item.currentStep = 'ts';
209
- const tsRemoved = await cleanTsArtifacts(pkg.dirname, dryRun);
290
+ const tsRemoved = await cleanTsArtifacts(pkg, dryRun);
210
291
  const buildInfoRemoved = await cleanTsBuildInfo(pkg.dirname, dryRun);
211
292
  item.currentStep = 'glob';
212
293
  const { include, exclude } = cleanConfig(pkg);
package/cli.js CHANGED
@@ -152,7 +152,11 @@ export async function runCli(options) {
152
152
  return { name: c.name, file: c.file, module: toCustomModule(c.custom, repository, app) };
153
153
  const declared = c.register(app);
154
154
  const meta = checkCustomCommand({ ...declared, command: declared.command?.trim() || c.name }, describeCommandSource(c.file));
155
- return { name: commandName(meta.command), file: c.file, module: toYargsCommand(meta) };
155
+ return {
156
+ name: commandName(meta.command),
157
+ file: c.file,
158
+ module: toYargsCommand(meta, args => commandContext(repository, app, args)),
159
+ };
156
160
  });
157
161
  /**
158
162
  * **One command per name, keeping the last** - which is the precedence that already applied,
@@ -210,7 +214,7 @@ export async function runCli(options) {
210
214
  for (const meta of builtIns) {
211
215
  if (shadowed.includes(meta))
212
216
  continue;
213
- program.command(toYargsCommand(meta));
217
+ program.command(toYargsCommand(meta, args => commandContext(repository, app, args)));
214
218
  }
215
219
  /** Said out loud at `verbose`, for the reason the contributed-on-contributed note above gives:
216
220
  * an override is correct and should not nag, but "rman test does something else here" needs a
@@ -550,17 +554,31 @@ function toCustomModule(custom, repository, app) {
550
554
  describe: custom.describe,
551
555
  configKeys: custom.configKeys,
552
556
  builder: custom.builder ?? ((y) => y),
553
- handler: (args) => {
554
- /** Resolved per invocation, not once at registration: `--log-level` is only known now. */
555
- const logLevel = args.logLevel ?? resolveRootLogLevel(repository);
556
- const context = {
557
- repository,
558
- package: repository.currentPackage,
559
- runBin: (bin, argv, opts) => runBin(bin, argv, { cwd: repository.dirname, logLevel, app, ...opts }),
560
- logger: new Logger(logLevel),
561
- };
562
- return custom.handler(context, args);
563
- },
557
+ handler: (args) => custom.handler(commandContext(repository, app, args), args),
558
+ };
559
+ }
560
+ /**
561
+ * What a command is handed beside its argv - **one object, built in one place, for both authoring
562
+ * forms.** A `.rman/*.mjs` command takes it as its first parameter and a declared command as its
563
+ * second; what is in it is the same either way, so a facility added here reaches both.
564
+ *
565
+ * **Built per invocation rather than once at registration**, which is the whole reason it exists
566
+ * separately from `app`: `app` is the application, this is *this run*. `--log-level` is only known
567
+ * now, and `runBin`/`logger` carry it - an imported `runBin` knows neither, so `--log-level silent`
568
+ * would quietly fail to apply to the one part of a command that produces output.
569
+ */
570
+ function commandContext(repository, app, args) {
571
+ const logLevel = args.logLevel ?? resolveRootLogLevel(repository);
572
+ const run = app.getService('run');
573
+ return {
574
+ repository,
575
+ package: repository.currentPackage,
576
+ runBin: (bin, argv, opts) => runBin(bin, argv, { cwd: repository.dirname, logLevel, app, ...opts }),
577
+ logger: new Logger(logLevel),
578
+ /** Bound, not passed as the service: a command should not have to know which service owns the
579
+ * scheduler, and `logLevel` is this run's rather than the repository's default. */
580
+ forEachPackage: (packages, fn, options) => run.forEachPackage(packages, fn, { logLevel, ...options }),
581
+ parallel: (tasks, options) => run.parallel(tasks, options ?? {}),
564
582
  };
565
583
  }
566
584
  /**
package/constants.js CHANGED
@@ -1 +1 @@
1
- export const version = '2.8.0';
1
+ export const version = '2.9.0';
@@ -38,6 +38,16 @@ export declare class Repository extends Package {
38
38
  * rewrites every bumped manifest and then re-interpolates its deferred hooks.
39
39
  */
40
40
  private readonly _files;
41
+ /**
42
+ * Every dependency cycle found in the **declared** graph, each one a path of package names that
43
+ * returns to where it started (`['@opra/cli', '@opra/api-ui', '@opra/cli']`). Empty for a graph
44
+ * that can be ordered.
45
+ *
46
+ * Recorded while the graph is built, from the *direct* edges - `Package.dependencies` is expanded
47
+ * transitively straight afterwards, and in the closure every member of a cycle depends on every
48
+ * other, so a path read back from it would name edges nobody declared.
49
+ */
50
+ protected _dependencyCycles: readonly (readonly string[])[];
41
51
  /**
42
52
  * The application this repository belongs to - its services, its technologies, its logger.
43
53
  *
@@ -62,6 +72,15 @@ export declare class Repository extends Package {
62
72
  * the repository root itself, or isn't inside any known package (e.g. a non-monorepo checkout,
63
73
  * or a stray directory the workspace glob doesn't cover).
64
74
  */
75
+ /**
76
+ * Every dependency cycle in the repository, each a path of package names returning to its start.
77
+ * Empty when the graph can be ordered.
78
+ *
79
+ * A cycle is not an error on its own - `list`, `info` and `config` answer perfectly well without
80
+ * an order, and refusing them would take away the commands you would use to find the cycle. It
81
+ * becomes one where something actually needs the order: see `getPackages({ toposort: true })`.
82
+ */
83
+ get dependencyCycles(): readonly (readonly string[])[];
65
84
  get currentPackage(): Package | undefined;
66
85
  getPackages(options?: {
67
86
  scope?: string | string[];
@@ -140,7 +159,26 @@ export declare class Repository extends Package {
140
159
  * function resolved against that package's scope.
141
160
  */
142
161
  protected _resolveConfigs(): Promise<void>;
162
+ /**
163
+ * Reorders `packages` in place so every package comes after the ones it depends on, keeping the
164
+ * given order among packages that do not constrain each other. Throws when they cannot be
165
+ * ordered - see `_describeCycle`.
166
+ *
167
+ * Only the edges *inside* `packages` count, so a `--scope` narrowing to part of the repository is
168
+ * ordered on its own terms.
169
+ */
143
170
  protected _topoSortPackages(packages: Package[]): void;
171
+ /**
172
+ * The message for a graph that cannot be ordered, naming the cycle rather than the packages that
173
+ * happened to be caught behind it.
174
+ *
175
+ * `stuck` is what Kahn's algorithm could not place, which is the cycle **plus everything
176
+ * downstream of it** - on `panates/opra` that was sixteen of twenty packages for one pair that
177
+ * named each other. So a recorded cycle lying entirely inside `stuck` is preferred, and the list
178
+ * is the fallback for the case a recorded path cannot cover: a cycle whose intermediate packages
179
+ * were filtered out by `--scope`, where the closure still pairs two of the survivors.
180
+ */
181
+ protected _describeCycle(stuck: Package[]): string;
144
182
  protected _packageScope(pkg: Package, targetVersion?: string): PackageScope;
145
183
  /** Built once and reused: it is the same for every package, and its `git` getter caches too, so a
146
184
  * repository whose config never mentions git spawns none. */
@@ -161,6 +199,16 @@ export declare class Repository extends Package {
161
199
  */
162
200
  protected _resolveDeclaredPackage(entry: string): Package | undefined;
163
201
  protected _updateDependencies(): void;
202
+ /**
203
+ * Every cycle in the declared graph, as a path of names returning to its start. Depth-first, so
204
+ * the path is the one a reader can follow edge by edge in the manifests.
205
+ *
206
+ * Each cycle is reported once: a package is left `done` after its subtree is walked, so a second
207
+ * route into it is not a second finding. The cost of that is real and deliberate - two cycles
208
+ * sharing a package may come back as one path - and the aim here is to *name* a cycle, not to
209
+ * enumerate them; the first one has to be broken either way.
210
+ */
211
+ protected _findDependencyCycles(direct: ReadonlyMap<Package, Package[]>): readonly (readonly string[])[];
164
212
  /** `git` facts for a `${{ git.* }}` expression. Synchronous on purpose: it backs a lazy
165
213
  * getter, and a getter cannot await. Everything is `undefined` outside a git checkout - not an
166
214
  * error, just a repository without one. */
@@ -42,6 +42,16 @@ export class Repository extends Package {
42
42
  * rewrites every bumped manifest and then re-interpolates its deferred hooks.
43
43
  */
44
44
  _files = new ConfigFileScope();
45
+ /**
46
+ * Every dependency cycle found in the **declared** graph, each one a path of package names that
47
+ * returns to where it started (`['@opra/cli', '@opra/api-ui', '@opra/cli']`). Empty for a graph
48
+ * that can be ordered.
49
+ *
50
+ * Recorded while the graph is built, from the *direct* edges - `Package.dependencies` is expanded
51
+ * transitively straight afterwards, and in the closure every member of a cycle depends on every
52
+ * other, so a path read back from it would name edges nobody declared.
53
+ */
54
+ _dependencyCycles = [];
45
55
  /**
46
56
  * The application this repository belongs to - its services, its technologies, its logger.
47
57
  *
@@ -79,6 +89,17 @@ export class Repository extends Package {
79
89
  * the repository root itself, or isn't inside any known package (e.g. a non-monorepo checkout,
80
90
  * or a stray directory the workspace glob doesn't cover).
81
91
  */
92
+ /**
93
+ * Every dependency cycle in the repository, each a path of package names returning to its start.
94
+ * Empty when the graph can be ordered.
95
+ *
96
+ * A cycle is not an error on its own - `list`, `info` and `config` answer perfectly well without
97
+ * an order, and refusing them would take away the commands you would use to find the cycle. It
98
+ * becomes one where something actually needs the order: see `getPackages({ toposort: true })`.
99
+ */
100
+ get dependencyCycles() {
101
+ return this._dependencyCycles;
102
+ }
82
103
  get currentPackage() {
83
104
  if (path.resolve(this.cwd) === path.resolve(this.dirname))
84
105
  return undefined;
@@ -269,14 +290,85 @@ export class Repository extends Package {
269
290
  if (this.monorepo)
270
291
  this.rootPackage.config = this.config;
271
292
  }
293
+ /**
294
+ * Reorders `packages` in place so every package comes after the ones it depends on, keeping the
295
+ * given order among packages that do not constrain each other. Throws when they cannot be
296
+ * ordered - see `_describeCycle`.
297
+ *
298
+ * Only the edges *inside* `packages` count, so a `--scope` narrowing to part of the repository is
299
+ * ordered on its own terms.
300
+ */
301
+ /* **This was a `packages.sort(...)` comparator and it did not sort topologically.** A comparator
302
+ * answering `0` for two packages that do not depend on each other is not a total order, and
303
+ * `Array.sort` is free to never compare two elements it has placed through a third - so an order
304
+ * can come out that no single comparison contradicts and that is still wrong. Measured on the
305
+ * smallest case there is: three packages `[c, b, a]` with the one edge `c -> a` came back
306
+ * `c b a`, the dependency last. `publish` and `docker-publish` take this order as their publish
307
+ * order, so a package shipped before the sibling it is built against.
308
+ *
309
+ * Kahn's algorithm instead, taking the lowest-numbered ready package each round so the input
310
+ * order survives among independents - which is what makes `rman list --toposort` stable and its
311
+ * output diffable. `Package.dependencies` is the transitive closure by the time this runs; the
312
+ * closure of a DAG is a DAG with the same topological orders, so the extra edges cost a little
313
+ * bookkeeping and change no answer. */
272
314
  _topoSortPackages(packages) {
273
- packages.sort((a, b) => {
274
- if (b.dependencies.includes(a))
275
- return -1;
276
- if (a.dependencies.includes(b))
277
- return 1;
278
- return 0;
279
- });
315
+ const inSubset = new Set(packages);
316
+ const position = new Map(packages.map((pkg, i) => [pkg, i]));
317
+ const waitingOn = new Map(packages.map(pkg => [pkg, 0]));
318
+ const dependents = new Map();
319
+ for (const pkg of packages) {
320
+ for (const dep of pkg.dependencies) {
321
+ if (!inSubset.has(dep))
322
+ continue;
323
+ waitingOn.set(pkg, waitingOn.get(pkg) + 1);
324
+ const list = dependents.get(dep);
325
+ if (list)
326
+ list.push(pkg);
327
+ else
328
+ dependents.set(dep, [pkg]);
329
+ }
330
+ }
331
+ const ordered = [];
332
+ const ready = packages.filter(pkg => waitingOn.get(pkg) === 0);
333
+ while (ready.length) {
334
+ ready.sort((a, b) => position.get(a) - position.get(b));
335
+ const pkg = ready.shift();
336
+ ordered.push(pkg);
337
+ for (const dependent of dependents.get(pkg) ?? []) {
338
+ const left = waitingOn.get(dependent) - 1;
339
+ waitingOn.set(dependent, left);
340
+ if (left === 0)
341
+ ready.push(dependent);
342
+ }
343
+ }
344
+ /** Anything left is in or above a cycle - the one thing a topological order cannot express. */
345
+ if (ordered.length < packages.length) {
346
+ const stuck = packages.filter(pkg => !ordered.includes(pkg));
347
+ throw new Error(this._describeCycle(stuck));
348
+ }
349
+ packages.length = 0;
350
+ packages.push(...ordered);
351
+ }
352
+ /**
353
+ * The message for a graph that cannot be ordered, naming the cycle rather than the packages that
354
+ * happened to be caught behind it.
355
+ *
356
+ * `stuck` is what Kahn's algorithm could not place, which is the cycle **plus everything
357
+ * downstream of it** - on `panates/opra` that was sixteen of twenty packages for one pair that
358
+ * named each other. So a recorded cycle lying entirely inside `stuck` is preferred, and the list
359
+ * is the fallback for the case a recorded path cannot cover: a cycle whose intermediate packages
360
+ * were filtered out by `--scope`, where the closure still pairs two of the survivors.
361
+ */
362
+ _describeCycle(stuck) {
363
+ const names = new Set(stuck.map(pkg => pkg.name));
364
+ const cycle = this._dependencyCycles.find(known => known.every(name => names.has(name)));
365
+ const what = cycle
366
+ ? `Dependency cycle: ${cycle.join(' -> ')}`
367
+ : `Dependency cycle among: ${stuck.map(pkg => pkg.name).join(', ')}`;
368
+ return (`${what}\n` +
369
+ ` Packages that depend on each other cannot be put in build order. Break the cycle in the\n` +
370
+ ` manifests (a peer marked "optional" is not an edge) or in .rmanrc "dependencies", or run\n` +
371
+ ` without ordering where the command offers it (--no-topo).`);
280
372
  }
281
373
  _packageScope(pkg, targetVersion) {
282
374
  // A manifest without a "name" is unusual but legal, and `info` prints such a package rather
@@ -359,10 +451,13 @@ export class Repository extends Package {
359
451
  }
360
452
  _updateDependencies() {
361
453
  const deps = {};
454
+ const direct = new Map();
362
455
  for (const pkg of this.packages) {
363
456
  /** Which manifest fields hold dependencies is the ecosystem's business, so the provider
364
457
  * reads them - npm's four field names used to be spelled out right here. */
365
- const dependencies = [...Manifest.dependenciesOf(pkg, this.packages)];
458
+ /** A package naming *itself* is dropped here, the same way the declared entries below are:
459
+ * it constrains nothing, and left in it would read as a one-package cycle. */
460
+ const dependencies = [...Manifest.dependenciesOf(pkg, this.packages)].filter(d => d !== pkg);
366
461
  /** `.rmanrc "dependencies"` on top, and it works with no provider at all: a repository rman
367
462
  * cannot read the manifests of can still declare its graph by hand. */
368
463
  const declared = pkg.config.dependencies ?? [];
@@ -373,7 +468,11 @@ export class Repository extends Package {
373
468
  }
374
469
  deps[pkg.name] = dependencies.map(d => d.name);
375
470
  pkg.dependencies = dependencies;
471
+ /** Copied, because the expansion below mutates `pkg.dependencies` in place - and what a
472
+ * reader needs to see is the edge somebody wrote, not the closure's. */
473
+ direct.set(pkg, [...dependencies]);
376
474
  }
475
+ this._dependencyCycles = this._findDependencyCycles(direct);
377
476
  let circularCheck;
378
477
  const deepFindDependencies = (pkg, target) => {
379
478
  if (circularCheck.includes(pkg))
@@ -395,6 +494,38 @@ export class Repository extends Package {
395
494
  deepFindDependencies(pkg, pkg.dependencies);
396
495
  }
397
496
  }
497
+ /**
498
+ * Every cycle in the declared graph, as a path of names returning to its start. Depth-first, so
499
+ * the path is the one a reader can follow edge by edge in the manifests.
500
+ *
501
+ * Each cycle is reported once: a package is left `done` after its subtree is walked, so a second
502
+ * route into it is not a second finding. The cost of that is real and deliberate - two cycles
503
+ * sharing a package may come back as one path - and the aim here is to *name* a cycle, not to
504
+ * enumerate them; the first one has to be broken either way.
505
+ */
506
+ _findDependencyCycles(direct) {
507
+ const cycles = [];
508
+ const state = new Map();
509
+ const trail = [];
510
+ const visit = (pkg) => {
511
+ const seen = state.get(pkg);
512
+ if (seen === 'done')
513
+ return;
514
+ if (seen === 'open') {
515
+ cycles.push([...trail.slice(trail.indexOf(pkg)), pkg].map(p => p.name));
516
+ return;
517
+ }
518
+ state.set(pkg, 'open');
519
+ trail.push(pkg);
520
+ for (const dep of direct.get(pkg) ?? [])
521
+ visit(dep);
522
+ trail.pop();
523
+ state.set(pkg, 'done');
524
+ };
525
+ for (const pkg of this.packages)
526
+ visit(pkg);
527
+ return cycles;
528
+ }
398
529
  /** `git` facts for a `${{ git.* }}` expression. Synchronous on purpose: it backs a lazy
399
530
  * getter, and a getter cannot await. Everything is `undefined` outside a git checkout - not an
400
531
  * error, just a repository without one. */
@@ -1,5 +1,6 @@
1
1
  import type { CommandModule } from 'yargs';
2
2
  import type { RmanConfig } from '../interfaces/rman-config.interface.js';
3
+ import type { CommandContext } from './interfaces/custom-command.js';
3
4
  /**
4
5
  * Turns a command's **declaration** into the yargs registration it describes.
5
6
  *
@@ -9,6 +10,13 @@ import type { RmanConfig } from '../interfaces/rman-config.interface.js';
9
10
  * switches - arrives as data now, so a typo in any of it is a compile error at the command rather
10
11
  * than a flag that silently never existed.
11
12
  */
12
- export declare function toYargsCommand(meta: RmanConfig.CommandMetadata): CommandModule;
13
+ export declare function toYargsCommand(meta: RmanConfig.CommandMetadata,
14
+ /**
15
+ * Builds the `CommandContext` the handler gets as its **second** parameter, per invocation.
16
+ *
17
+ * Optional because a caller with no repository in hand (a spec listing the registrations) has
18
+ * nothing to build one from, and a handler that never reads it cannot tell.
19
+ */
20
+ contextFor?: (args: any) => CommandContext): CommandModule;
13
21
  /** A command's own name - the first word of its `command` string, before any positional. */
14
22
  export declare function commandName(command: string): string;
@@ -7,7 +7,14 @@
7
7
  * switches - arrives as data now, so a typo in any of it is a compile error at the command rather
8
8
  * than a flag that silently never existed.
9
9
  */
10
- export function toYargsCommand(meta) {
10
+ export function toYargsCommand(meta,
11
+ /**
12
+ * Builds the `CommandContext` the handler gets as its **second** parameter, per invocation.
13
+ *
14
+ * Optional because a caller with no repository in hand (a spec listing the registrations) has
15
+ * nothing to build one from, and a handler that never reads it cannot tell.
16
+ */
17
+ contextFor) {
11
18
  return {
12
19
  command: meta.command,
13
20
  aliases: meta.aliases,
@@ -51,23 +58,29 @@ export function toYargsCommand(meta) {
51
58
  * narrower one is the useful one, and this is the single place the widening is admitted -
52
59
  * previously it was an `as` per option read, in every handler.
53
60
  */
54
- handler: withCommandPlatforms(meta),
61
+ handler: wrapHandler(meta, contextFor),
55
62
  };
56
63
  }
57
64
  /**
58
- * Carries `CommandMetadata.platforms` into argv, where `readPackageFilterOptions` picks it up.
65
+ * The handler yargs is given: carries `CommandMetadata.platforms` into argv, and hands the command
66
+ * its `CommandContext` as a second parameter.
67
+ *
68
+ * **`(args, context)`, not `(context, args)`** - the opposite order to `CustomCommand.handler`, and
69
+ * decided by compatibility rather than taste: every declared command in existence is `handler(args)`,
70
+ * so appending a parameter breaks none of them while prepending one breaks all of them. The two
71
+ * authoring forms therefore disagree about the order and agree about everything in the object,
72
+ * which is the cheaper of the two inconsistencies.
59
73
  *
60
- * Through argv rather than a parameter because `filterPackages` is called by ten *services*, none
61
- * of which knows which command is running - `options` is all they see. One wrap here reaches every
62
- * one of them and changes none.
74
+ * `platforms` goes through argv rather than a parameter because `filterPackages` is called by ten
75
+ * *services*, none of which knows which command is running - `options` is all they see. One wrap
76
+ * here reaches every one of them and changes none.
63
77
  */
64
- function withCommandPlatforms(meta) {
78
+ function wrapHandler(meta, contextFor) {
65
79
  const handler = meta.handler;
66
- if (!meta.platforms?.length)
67
- return handler;
68
80
  return (args) => {
69
- args.commandPlatforms = meta.platforms;
70
- return handler(args);
81
+ if (meta.platforms?.length)
82
+ args.commandPlatforms = meta.platforms;
83
+ return handler(args, contextFor?.(args));
71
84
  };
72
85
  }
73
86
  /** A command's own name - the first word of its `command` string, before any positional. */