rman 2.8.0 → 2.10.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/builtins/platforms/node/node-manifest.provider.d.ts +10 -0
- package/builtins/platforms/node/node-manifest.provider.js +32 -5
- package/builtins/platforms/node/services/clean.service.js +104 -23
- package/cli.js +31 -13
- package/commands/changelog.command.d.ts +15 -0
- package/constants.js +1 -1
- package/core/classes/repository.d.ts +48 -0
- package/core/classes/repository.js +139 -8
- package/core/command-builder.d.ts +9 -1
- package/core/command-builder.js +24 -11
- package/core/config/config-paths.js +24 -0
- package/core/interfaces/custom-command.d.ts +39 -0
- package/core/interfaces/run-step.d.ts +50 -3
- package/index.d.ts +7 -1
- package/index.js +11 -0
- package/interfaces/rman-config.interface.d.ts +21 -3
- package/package.json +1 -1
- package/services/changelog.service.js +120 -14
- package/services/run.service.d.ts +125 -1
- package/services/run.service.js +388 -120
- package/services/version.service.d.ts +2 -0
- package/services/version.service.js +7 -2
- package/utils/run-options.d.ts +44 -0
- package/utils/run-options.js +55 -0
|
@@ -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
|
|
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
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
|
|
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
|
-
/**
|
|
80
|
-
*
|
|
81
|
-
|
|
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
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
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
|
-
|
|
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 (
|
|
171
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
/**
|
|
@@ -184,6 +184,21 @@ declare const config: {
|
|
|
184
184
|
* from commit type to section heading, and an option says `type: 'string'`.
|
|
185
185
|
*/
|
|
186
186
|
export interface ChangelogExtraKeys {
|
|
187
|
+
/**
|
|
188
|
+
* Where each **named** group's changelog is written under `groupBy: 'group'`, relative to the
|
|
189
|
+
* repository root - `{ core: 'packages/core/CHANGELOG.md', dialects: 'docs/dialects.md' }`.
|
|
190
|
+
* Read off the repository root only.
|
|
191
|
+
*
|
|
192
|
+
* A group not listed keeps the default rule, `CHANGELOG-<group>.md` beside the default file; the
|
|
193
|
+
* default group's own file is `changelog.filePath`. A key naming no group, a path outside the
|
|
194
|
+
* repository, and two groups resolving to one file are all refused before anything is written.
|
|
195
|
+
* An explicit `--file-path` overrides the whole map for that run.
|
|
196
|
+
*
|
|
197
|
+
* **Move an existing file with `git mv`, not by changing the path here.** A file's
|
|
198
|
+
* `documented-up-to` marker is what the next `--write` starts from; at a new path there is none,
|
|
199
|
+
* and the write reads the group's entire history.
|
|
200
|
+
*/
|
|
201
|
+
groupFiles?: ConfigValue<Record<string, string>>;
|
|
187
202
|
/**
|
|
188
203
|
* The heading each Conventional Commits type is listed under - `{ feat: 'New Features', dev:
|
|
189
204
|
* 'Development Changes' }`. Per-package cascaded.
|
package/constants.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = '2.
|
|
1
|
+
export const version = '2.10.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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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
|
|
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;
|