@sous-io/sous 0.2.28 → 0.2.30
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/docs/markdown/_sidebar.md +2 -0
- package/docs/markdown/commands.md +10 -6
- package/docs/markdown/repositories-authoring.md +19 -11
- package/docs/markdown/repositories-consuming.md +34 -13
- package/docs/markdown/repositories-file-formats.md +88 -13
- package/docs/markdown/repositories-providers.md +8 -6
- package/docs/markdown/repositories-troubleshooting.md +6 -0
- package/docs/markdown/repositories-variables.md +13 -3
- package/docs/markdown/repositories.md +3 -3
- package/package.json +1 -1
- package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +11 -7
- package/recipes/core/sous-skills/skills/about-sous-repos/SKILL.tpl.md +7 -4
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/src/commands/lock/rebuild.ts +7 -6
- package/src/commands/recipe/show.ts +163 -64
- package/src/commands/repo/contribute.ts +1 -10
- package/src/commands/repo/gc.ts +3 -2
- package/src/commands/repo/release.ts +20 -3
- package/src/commands/repo/search.ts +2 -1
- package/src/commands/vars/ask.ts +32 -10
- package/src/lib/refs/find.ts +202 -21
- package/src/lib/refs/index.ts +2 -0
- package/src/lib/refs/parse.ts +844 -0
- package/src/lib/refs/scopes.ts +34 -0
- package/src/lib/refs/settle.ts +219 -0
- package/src/lib/repos/catalog.ts +87 -80
- package/src/lib/repos/declarations.ts +177 -0
- package/src/lib/repos/formats/common.ts +14 -0
- package/src/lib/repos/formats/index-file.ts +42 -2
- package/src/lib/repos/formats/lockfile.ts +5 -3
- package/src/lib/repos/formats/recipe-manifest.ts +16 -212
- package/src/lib/repos/formats/variable-definition.ts +268 -0
- package/src/lib/repos/index.ts +0 -1
- package/src/lib/repos/lock-service.ts +3 -2
- package/src/lib/repos/locked-namespace-resolver.ts +21 -15
- package/src/lib/repos/locked-recipes.ts +2 -2
- package/src/lib/repos/namespace-resolver.ts +40 -47
- package/src/lib/repos/providers/base.ts +30 -0
- package/src/lib/repos/providers/github.ts +22 -0
- package/src/lib/repos/providers/gitlab.ts +68 -0
- package/src/lib/repos/providers/provider.ts +96 -0
- package/src/lib/repos/release/index-builder.ts +221 -47
- package/src/lib/repos/release/index.ts +1 -0
- package/src/lib/repos/release/plan.ts +8 -6
- package/src/lib/repos/release/settle.ts +237 -0
- package/src/lib/repos/release/tags.ts +3 -7
- package/src/lib/repos/resolver.ts +251 -53
- package/src/lib/repos/subscription-service.ts +355 -49
- package/src/lib/repos/update-plan.ts +3 -2
- package/src/lib/vars/mappings.ts +17 -30
- package/src/lib/vars/question-plan.ts +44 -21
- package/src/lib/repos/ref.ts +0 -513
|
@@ -28,7 +28,11 @@ import { fetchText, type FetchLike } from "./http.js";
|
|
|
28
28
|
import {
|
|
29
29
|
buildCanonicalRepo,
|
|
30
30
|
invalidRepoUrl,
|
|
31
|
+
readingsAfterRepository,
|
|
31
32
|
splitRepoUrl,
|
|
33
|
+
withoutGitSuffix,
|
|
34
|
+
type LocationReading,
|
|
35
|
+
type WrittenLocation,
|
|
32
36
|
type AuthStatus,
|
|
33
37
|
type CanonicalRepo,
|
|
34
38
|
type ChangeProposal,
|
|
@@ -80,6 +84,70 @@ export class GitlabProvider extends ProviderBase {
|
|
|
80
84
|
/** What GitLab calls a proposal. */
|
|
81
85
|
readonly proposalNoun = "merge request";
|
|
82
86
|
|
|
87
|
+
/** The host a `gitlab://` locator means when it names none. */
|
|
88
|
+
readonly defaultHost = GITLAB_HOST;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A GitLab project path nests (`group/subgroup/project`), so where it ends is
|
|
92
|
+
* only certain when the location says so: a `-` segment (GitLab's own
|
|
93
|
+
* separator, as in `/-/tree/main/...`) or a `.git` suffix ends it. Otherwise
|
|
94
|
+
* every split that leaves a project of at least two segments and at most a
|
|
95
|
+
* namespace and a recipe after it is a reading, and the caller keeps the one
|
|
96
|
+
* an index confirms.
|
|
97
|
+
*
|
|
98
|
+
* readLocation({ host: "gitlab.com", segments: ["a", "b", "c", "d"] });
|
|
99
|
+
* // -> project "a/b" naming "c/d", project "a/b/c" naming "d",
|
|
100
|
+
* // and project "a/b/c/d" naming nothing
|
|
101
|
+
*
|
|
102
|
+
* @param location - The host and the segments after it.
|
|
103
|
+
*/
|
|
104
|
+
readLocation(location: WrittenLocation): LocationReading[] {
|
|
105
|
+
const { segments } = location;
|
|
106
|
+
|
|
107
|
+
const separator = segments.indexOf("-");
|
|
108
|
+
if (separator >= 2) {
|
|
109
|
+
return readingsAfterRepository(
|
|
110
|
+
this.projectPath(segments.slice(0, separator)),
|
|
111
|
+
segments.slice(separator)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const suffixed = segments.findIndex((segment) => /\.git$/i.test(segment));
|
|
116
|
+
if (suffixed >= 1) {
|
|
117
|
+
return readingsAfterRepository(
|
|
118
|
+
this.projectPath(segments.slice(0, suffixed + 1)),
|
|
119
|
+
segments.slice(suffixed + 1)
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const readings: LocationReading[] = [];
|
|
124
|
+
for (let end = 2; end <= segments.length; end++) {
|
|
125
|
+
const rest = segments.slice(end);
|
|
126
|
+
if (rest.length > 2) continue;
|
|
127
|
+
readings.push({ repoPath: this.projectPath(segments.slice(0, end)), named: rest });
|
|
128
|
+
}
|
|
129
|
+
return readings;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The canonical locator always marks where the project path ends with `-`,
|
|
134
|
+
* because without it a nested group reads more than one way.
|
|
135
|
+
*
|
|
136
|
+
* @param host - The project's host.
|
|
137
|
+
* @param repoPath - The project's path on that host.
|
|
138
|
+
* @param rest - What is named inside it.
|
|
139
|
+
*/
|
|
140
|
+
formatLocator(host: string, repoPath: string, rest: string): string {
|
|
141
|
+
const where = host === this.defaultHost ? repoPath : `${host}/${repoPath}`;
|
|
142
|
+
return `${this.id}://${where}${rest.length === 0 ? "" : `/-/${rest}`}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A project path from its segments, with the last one's `.git` gone. */
|
|
146
|
+
private projectPath(segments: string[]): string {
|
|
147
|
+
const last = segments.length - 1;
|
|
148
|
+
return segments.map((segment, index) => (index === last ? withoutGitSuffix(segment) : segment)).join("/");
|
|
149
|
+
}
|
|
150
|
+
|
|
83
151
|
matches(url: string): boolean {
|
|
84
152
|
const parts = splitRepoUrl(url);
|
|
85
153
|
if (parts === undefined) return false;
|
|
@@ -56,6 +56,44 @@ export type CanonicalRepo = {
|
|
|
56
56
|
sshUrl: string;
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* A location as written in a ref, taken apart only as far as every host agrees:
|
|
61
|
+
* the host, and the path segments after it. Which of those segments are the
|
|
62
|
+
* repository and which name something inside it is a host's own rule, so a
|
|
63
|
+
* provider answers that (see `RepoProvider.readLocation`).
|
|
64
|
+
*
|
|
65
|
+
* https://gitlab.com/group/sub/project/-/tree/main/recipes/workflow/alpha
|
|
66
|
+
* // -> { host: "gitlab.com",
|
|
67
|
+
* // segments: ["group", "sub", "project", "-", "tree", "main", ...] }
|
|
68
|
+
*/
|
|
69
|
+
export type WrittenLocation = {
|
|
70
|
+
/** The host, lowercased, such as `github.com`. */
|
|
71
|
+
host: string;
|
|
72
|
+
/** Every path segment after the host, in order, none of them empty. */
|
|
73
|
+
segments: string[];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One way a provider reads a written location. A location on a host where a
|
|
78
|
+
* repository path can be any length (a GitLab group nests) may have several.
|
|
79
|
+
*/
|
|
80
|
+
export type LocationReading = {
|
|
81
|
+
/** The repository's path on the host, such as `sous-io/sous-recipes`, with no `.git`. */
|
|
82
|
+
repoPath: string;
|
|
83
|
+
/**
|
|
84
|
+
* The segments after the repository that name something inside it: a
|
|
85
|
+
* namespace, a namespace and a recipe, or a namespace and `*`. Empty when the
|
|
86
|
+
* location names only the repository.
|
|
87
|
+
*/
|
|
88
|
+
named?: string[];
|
|
89
|
+
/**
|
|
90
|
+
* A browser URL's path inside the repository, copied from a host's file view
|
|
91
|
+
* (after `tree/` or `blob/`), with the branch still in front of it. It is
|
|
92
|
+
* settled against the `path` each recipe's index entry records.
|
|
93
|
+
*/
|
|
94
|
+
browsed?: string;
|
|
95
|
+
};
|
|
96
|
+
|
|
59
97
|
/** Options every provider call accepts, all of them for testing seams. */
|
|
60
98
|
export type ProviderOptions = {
|
|
61
99
|
/**
|
|
@@ -215,6 +253,27 @@ export interface RepoProvider {
|
|
|
215
253
|
readonly id: ProviderId;
|
|
216
254
|
/** What this provider can do; see ProviderFeature. */
|
|
217
255
|
readonly features: ProviderFeature[];
|
|
256
|
+
/**
|
|
257
|
+
* The host a locator such as `github://owner/repo/...` means when it names
|
|
258
|
+
* none. A provider without a public host of its own leaves it out.
|
|
259
|
+
*/
|
|
260
|
+
readonly defaultHost?: string;
|
|
261
|
+
/**
|
|
262
|
+
* Every way this provider reads a location inside one of its repositories:
|
|
263
|
+
* where the repository path ends, and whether the rest names a namespace, a
|
|
264
|
+
* recipe or a browser path. An empty list means the provider reads no such
|
|
265
|
+
* location.
|
|
266
|
+
*/
|
|
267
|
+
readLocation(location: WrittenLocation): LocationReading[];
|
|
268
|
+
/**
|
|
269
|
+
* The canonical locator for something inside one of this provider's
|
|
270
|
+
* repositories, which is how sous prints a located ref.
|
|
271
|
+
*
|
|
272
|
+
* @param host - The repository's host.
|
|
273
|
+
* @param repoPath - The repository's path on that host.
|
|
274
|
+
* @param rest - What is named inside it, such as `workflow/alpha`.
|
|
275
|
+
*/
|
|
276
|
+
formatLocator(host: string, repoPath: string, rest: string): string;
|
|
218
277
|
/** True when this provider handles the given repository URL. */
|
|
219
278
|
matches(url: string): boolean;
|
|
220
279
|
/** Takes a repository URL apart, raising a ConfigError when it does not fit. */
|
|
@@ -339,6 +398,43 @@ export function supportsSubmit(provider: RepoProvider): provider is SubmitCapabl
|
|
|
339
398
|
);
|
|
340
399
|
}
|
|
341
400
|
|
|
401
|
+
/** The words a host's file view puts between a repository and a path: `tree/` and `blob/`. */
|
|
402
|
+
const BROWSE_WORDS = new Set(["tree", "blob"]);
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* A repository's last path segment without a `.git` suffix.
|
|
406
|
+
*
|
|
407
|
+
* @param segment - The segment as written.
|
|
408
|
+
*/
|
|
409
|
+
export function withoutGitSuffix(segment: string): string {
|
|
410
|
+
return segment.replace(/\.git$/i, "");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Every reading of the segments after a repository: what they name, and, when
|
|
415
|
+
* they start with `tree/` or `blob/`, the browser path they could also be. Both
|
|
416
|
+
* are returned when both are possible (a namespace could be called `tree`), and
|
|
417
|
+
* the caller keeps the one the repository's index confirms. A leading `-`
|
|
418
|
+
* segment (GitLab's separator between a project and its pages) is dropped
|
|
419
|
+
* first, wherever it is written.
|
|
420
|
+
*
|
|
421
|
+
* readingsAfterRepository("o/r", ["tree", "main", "recipes", "workflow", "alpha"]);
|
|
422
|
+
* // -> [{ repoPath: "o/r", browsed: "main/recipes/workflow/alpha" },
|
|
423
|
+
* // { repoPath: "o/r", named: ["tree", "main", "recipes", "workflow", "alpha"] }]
|
|
424
|
+
*
|
|
425
|
+
* @param repoPath - The repository's path on its host.
|
|
426
|
+
* @param rest - The segments after it.
|
|
427
|
+
*/
|
|
428
|
+
export function readingsAfterRepository(repoPath: string, rest: string[]): LocationReading[] {
|
|
429
|
+
const after = rest[0] === "-" ? rest.slice(1) : rest;
|
|
430
|
+
const readings: LocationReading[] = [];
|
|
431
|
+
if (after.length >= 2 && BROWSE_WORDS.has(after[0]!.toLowerCase())) {
|
|
432
|
+
readings.push({ repoPath, browsed: after.slice(1).join("/") });
|
|
433
|
+
}
|
|
434
|
+
readings.push({ repoPath, named: after });
|
|
435
|
+
return readings;
|
|
436
|
+
}
|
|
437
|
+
|
|
342
438
|
/**
|
|
343
439
|
* Normalizes a repository URL for matching: trims it, drops a trailing slash
|
|
344
440
|
* and a trailing `.git`, and lowercases the scheme and host only.
|
|
@@ -26,6 +26,13 @@
|
|
|
26
26
|
* its ranges long afterwards. Those dependencies are resolved exactly once: when
|
|
27
27
|
* the version is first recorded, against the repository as it stands then (or,
|
|
28
28
|
* for a version rebuilt from its tag, as it stood at that tag), and never again.
|
|
29
|
+
*
|
|
30
|
+
* A version recorded here is also DESCRIBED, so a consumer can say what
|
|
31
|
+
* subscribing to it brings in and asks before anything is fetched: each
|
|
32
|
+
* dependency carries the manifest entry that declared it and whether it is a
|
|
33
|
+
* co-subscription or a build dependency, and the version carries the variable
|
|
34
|
+
* definitions its manifest publishes. Those fields are frozen with the rest of
|
|
35
|
+
* the entry; a version published before they existed keeps its entry as it is.
|
|
29
36
|
*/
|
|
30
37
|
|
|
31
38
|
import fs from "node:fs";
|
|
@@ -60,7 +67,17 @@ import {
|
|
|
60
67
|
type ValidatedRecipe,
|
|
61
68
|
type ValidationProblem,
|
|
62
69
|
} from "./validate.js";
|
|
63
|
-
import {
|
|
70
|
+
import {
|
|
71
|
+
describeDependencyKind,
|
|
72
|
+
foldDeclaration,
|
|
73
|
+
type CoveringEntry,
|
|
74
|
+
type DependencyDeclaration,
|
|
75
|
+
type GatheredDeclaration,
|
|
76
|
+
} from "../declarations.js";
|
|
77
|
+
import type { DependencyKind } from "../formats/common.js";
|
|
78
|
+
import { isNamedReading, namespaceOfKey, parseRef, refKey } from "../../refs/parse.js";
|
|
79
|
+
import { RefSource } from "../../refs/scopes.js";
|
|
80
|
+
import type { SettledDependency } from "./settle.js";
|
|
64
81
|
|
|
65
82
|
/** A version that is ready to publish but has no tag yet. */
|
|
66
83
|
export type PendingRelease = {
|
|
@@ -113,6 +130,12 @@ export type BuildIndexOptions = {
|
|
|
113
130
|
* index that describes itself.
|
|
114
131
|
*/
|
|
115
132
|
publishing?: Record<string, string>;
|
|
133
|
+
/**
|
|
134
|
+
* The dependencies that read more than one way, as `settleDependencyLocations`
|
|
135
|
+
* settled them, keyed by the dependency as written. Each is recorded under
|
|
136
|
+
* the keys it reached, with the repository it settled on.
|
|
137
|
+
*/
|
|
138
|
+
settled?: Map<string, SettledDependency>;
|
|
116
139
|
};
|
|
117
140
|
|
|
118
141
|
/** The index file's absolute path in a repository. */
|
|
@@ -181,14 +204,22 @@ export async function buildIndex(
|
|
|
181
204
|
// repository as it stood at the tag, which is what it was released against.
|
|
182
205
|
for (const tag of recipeTags) {
|
|
183
206
|
if (Object.hasOwn(versions, tag.version)) continue;
|
|
184
|
-
const rebuilt = await rebuildTaggedVersion(
|
|
207
|
+
const rebuilt = await rebuildTaggedVersion(
|
|
208
|
+
rootDir,
|
|
209
|
+
tag,
|
|
210
|
+
recipe,
|
|
211
|
+
tagsByKey,
|
|
212
|
+
existing,
|
|
213
|
+
run,
|
|
214
|
+
options.settled
|
|
215
|
+
);
|
|
185
216
|
problems.push(...rebuilt.problems);
|
|
186
217
|
versions[tag.version] = {
|
|
187
218
|
hash: rebuilt.hash,
|
|
188
219
|
tag: tag.tag,
|
|
189
220
|
prerelease: semver.prerelease(tag.version) !== null,
|
|
190
221
|
...(await releasedAtOf(rootDir, tag.tag, run)),
|
|
191
|
-
...
|
|
222
|
+
...rebuilt.description,
|
|
192
223
|
};
|
|
193
224
|
}
|
|
194
225
|
|
|
@@ -197,7 +228,7 @@ export async function buildIndex(
|
|
|
197
228
|
const hasTag = recipeTags.some((entry) => entry.tag === tagName);
|
|
198
229
|
const workingHash = await hashDirectory(recipe.dir);
|
|
199
230
|
const where = relativeTo(rootDir, recipe.manifestPath);
|
|
200
|
-
const declared = declaredDependencies(recipe, validation);
|
|
231
|
+
const declared = declaredDependencies(recipe, validation, options.settled);
|
|
201
232
|
|
|
202
233
|
// A version the index publishes must have a tag, with one exception: the
|
|
203
234
|
// version the manifest declares right now, which is the one a release is in
|
|
@@ -249,6 +280,9 @@ export async function buildIndex(
|
|
|
249
280
|
...checkRecordedDependencies(key, version, published.dependencies, declared)
|
|
250
281
|
);
|
|
251
282
|
}
|
|
283
|
+
if (taggedHash === workingHash) {
|
|
284
|
+
problems.push(...checkRecordedLists(key, version, published, recipe));
|
|
285
|
+
}
|
|
252
286
|
}
|
|
253
287
|
// A tagged version the index did not record was rebuilt from its tag above.
|
|
254
288
|
|
|
@@ -278,13 +312,12 @@ export async function buildIndex(
|
|
|
278
312
|
// follows on the very commit that carries this index.
|
|
279
313
|
const published = existingVersions[version];
|
|
280
314
|
if (options.publishing?.[key] === version || published !== undefined) {
|
|
281
|
-
const dependencies = resolveIndexDependencies(declared, current);
|
|
282
315
|
versions[version] = {
|
|
283
316
|
hash: workingHash,
|
|
284
317
|
tag: tagName,
|
|
285
318
|
prerelease: semver.prerelease(version) !== null,
|
|
286
319
|
releasedAt: published?.releasedAt ?? now.toISOString(),
|
|
287
|
-
...(
|
|
320
|
+
...describeVersion(recipe, declared, current),
|
|
288
321
|
};
|
|
289
322
|
}
|
|
290
323
|
|
|
@@ -419,10 +452,10 @@ export function describeIndexDrift(
|
|
|
419
452
|
* `DeclaredDependencies`. A whole-namespace declaration is expanded into one
|
|
420
453
|
* entry per recipe the namespace holds.
|
|
421
454
|
*/
|
|
422
|
-
type
|
|
455
|
+
type DeclaredLocation =
|
|
423
456
|
| {
|
|
424
457
|
/** A recipe in another repository, named by its location. */
|
|
425
|
-
|
|
458
|
+
where: "remote";
|
|
426
459
|
/** The canonical identity of the repository publishing it. */
|
|
427
460
|
repo: string;
|
|
428
461
|
/** The range the manifest declared, `*` when it declared none. */
|
|
@@ -430,7 +463,7 @@ type DeclaredDependency =
|
|
|
430
463
|
}
|
|
431
464
|
| {
|
|
432
465
|
/** A recipe in this same repository. */
|
|
433
|
-
|
|
466
|
+
where: "sibling";
|
|
434
467
|
/** The range the manifest declared, when it declared one. */
|
|
435
468
|
range?: string;
|
|
436
469
|
/**
|
|
@@ -442,6 +475,9 @@ type DeclaredDependency =
|
|
|
442
475
|
named: boolean;
|
|
443
476
|
};
|
|
444
477
|
|
|
478
|
+
/** One declared dependency: where it lives, and the manifest entry bringing it in. */
|
|
479
|
+
type DeclaredDependency = DeclaredLocation & DependencyDeclaration;
|
|
480
|
+
|
|
445
481
|
/** Everything one recipe's manifest declares, under `depends` and `subscribes`. */
|
|
446
482
|
type DeclaredDependencies = {
|
|
447
483
|
/** Every dependency, keyed `namespace/recipe`; a later declaration wins over an earlier one. */
|
|
@@ -464,49 +500,80 @@ type SiblingState = {
|
|
|
464
500
|
*
|
|
465
501
|
* @param recipe - The recipe whose dependencies are being read.
|
|
466
502
|
* @param validation - The validated repository, for expanding namespace refs.
|
|
503
|
+
* @param settled - The dependencies that read more than one way, as the release settled them.
|
|
467
504
|
*/
|
|
468
505
|
function declaredDependencies(
|
|
469
506
|
recipe: ValidatedRecipe,
|
|
470
|
-
validation: RepoValidation
|
|
507
|
+
validation: RepoValidation,
|
|
508
|
+
settled: Map<string, SettledDependency> = new Map()
|
|
471
509
|
): DeclaredDependencies {
|
|
472
|
-
const byKey = new Map<string,
|
|
510
|
+
const byKey = new Map<string, DeclaredLocation>();
|
|
473
511
|
const namespaces = new Set<string>();
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
512
|
+
// Which entry brings each recipe in, and whether as a co-subscription, by the
|
|
513
|
+
// rule every reader of the index shares (`foldDeclaration`).
|
|
514
|
+
const declarations = new Map<string, GatheredDeclaration>();
|
|
515
|
+
const declared: ReadonlyArray<readonly [DependencyKind, string]> = [
|
|
516
|
+
...(recipe.manifest.depends ?? []).map((written) => ["depends", written] as const),
|
|
517
|
+
...(recipe.manifest.subscribes ?? []).map((written) => ["subscribes", written] as const),
|
|
477
518
|
];
|
|
478
519
|
|
|
520
|
+
/** Records that one entry covers one recipe. */
|
|
521
|
+
const cover = (key: string, entry: CoveringEntry): void => {
|
|
522
|
+
declarations.set(key, foldDeclaration(declarations.get(key), entry));
|
|
523
|
+
};
|
|
524
|
+
|
|
479
525
|
/** Records one sibling, remembering whether any declaration named it. */
|
|
480
526
|
const addSibling = (key: string, range: string | undefined, named: boolean): void => {
|
|
481
527
|
const before = byKey.get(key);
|
|
482
|
-
const namedBefore = before?.
|
|
528
|
+
const namedBefore = before?.where === "sibling" && before.named;
|
|
483
529
|
byKey.set(key, {
|
|
484
|
-
|
|
530
|
+
where: "sibling",
|
|
485
531
|
...(range === undefined ? {} : { range }),
|
|
486
532
|
named: named || namedBefore,
|
|
487
533
|
});
|
|
488
534
|
};
|
|
489
535
|
|
|
490
|
-
for (const written of declared) {
|
|
491
|
-
|
|
536
|
+
for (const [kind, written] of declared) {
|
|
537
|
+
// A dependency that reads more than one way was settled by the release,
|
|
538
|
+
// and what it settled on is what a consumer reads instead of probing.
|
|
539
|
+
const answer = settled.get(written.trim());
|
|
540
|
+
if (answer !== undefined) {
|
|
541
|
+
for (const key of answer.keys) {
|
|
542
|
+
byKey.set(key, { where: "remote", repo: answer.identity, range: answer.range ?? "*" });
|
|
543
|
+
cover(key, { written, kind, named: answer.keys.length === 1 });
|
|
544
|
+
}
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
let readings;
|
|
492
549
|
try {
|
|
493
|
-
|
|
550
|
+
readings = parseRef(written, RefSource.Manifest);
|
|
494
551
|
} catch {
|
|
495
552
|
// A dependency that does not parse is already reported by validation.
|
|
496
553
|
continue;
|
|
497
554
|
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
555
|
+
// Several readings, or a browser path, are settled above or reported by
|
|
556
|
+
// the settling step; there is nothing more to record here.
|
|
557
|
+
const parsed = readings[0]!;
|
|
558
|
+
if (readings.length !== 1 || !isNamedReading(parsed)) continue;
|
|
559
|
+
|
|
560
|
+
if (parsed.location !== undefined) {
|
|
561
|
+
// A whole namespace in another repository is read from that repository's
|
|
562
|
+
// own index by the consumer; only a recipe has a key to record.
|
|
563
|
+
if (parsed.recipe !== undefined) {
|
|
564
|
+
byKey.set(refKey(parsed), {
|
|
565
|
+
where: "remote",
|
|
566
|
+
repo: parsed.location.identity,
|
|
567
|
+
range: parsed.range ?? "*",
|
|
568
|
+
});
|
|
569
|
+
cover(refKey(parsed), { written, kind, named: true });
|
|
570
|
+
}
|
|
505
571
|
continue;
|
|
506
572
|
}
|
|
507
573
|
|
|
508
574
|
if (parsed.recipe !== undefined) {
|
|
509
|
-
addSibling(
|
|
575
|
+
addSibling(refKey(parsed), parsed.range, true);
|
|
576
|
+
cover(refKey(parsed), { written, kind, named: true });
|
|
510
577
|
continue;
|
|
511
578
|
}
|
|
512
579
|
|
|
@@ -516,10 +583,17 @@ function declaredDependencies(
|
|
|
516
583
|
if (entry.manifest.namespace !== parsed.namespace) continue;
|
|
517
584
|
if (entry.key === recipe.key) continue;
|
|
518
585
|
addSibling(entry.key, undefined, false);
|
|
586
|
+
cover(entry.key, { written, kind, named: false });
|
|
519
587
|
}
|
|
520
588
|
}
|
|
521
589
|
|
|
522
|
-
|
|
590
|
+
const described = new Map<string, DeclaredDependency>();
|
|
591
|
+
for (const [key, dependency] of byKey) {
|
|
592
|
+
const { declared: entry, kind } = declarations.get(key)!;
|
|
593
|
+
described.set(key, { ...dependency, declared: entry, kind });
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return { byKey: described, namespaces };
|
|
523
597
|
}
|
|
524
598
|
|
|
525
599
|
/**
|
|
@@ -592,24 +666,105 @@ function siblingState(
|
|
|
592
666
|
function resolveIndexDependencies(
|
|
593
667
|
declared: DeclaredDependencies,
|
|
594
668
|
siblings: SiblingState
|
|
595
|
-
): Record<string, IndexDependency>
|
|
669
|
+
): Record<string, IndexDependency> {
|
|
596
670
|
const resolved: Record<string, IndexDependency> = {};
|
|
597
671
|
|
|
598
672
|
for (const [key, dependency] of declared.byKey) {
|
|
599
|
-
|
|
600
|
-
|
|
673
|
+
const how = { declared: dependency.declared, kind: dependency.kind };
|
|
674
|
+
if (dependency.where === "remote") {
|
|
675
|
+
resolved[key] = { repo: dependency.repo, range: dependency.range, ...how };
|
|
601
676
|
continue;
|
|
602
677
|
}
|
|
603
678
|
if (dependency.range === undefined) {
|
|
604
679
|
const settled = siblings.settled.get(key);
|
|
605
|
-
resolved[key] =
|
|
680
|
+
resolved[key] = {
|
|
681
|
+
...(settled === undefined ? { range: "*" } : { version: settled }),
|
|
682
|
+
...how,
|
|
683
|
+
};
|
|
606
684
|
continue;
|
|
607
685
|
}
|
|
608
686
|
const best = semver.maxSatisfying(siblings.published.get(key) ?? [], dependency.range);
|
|
609
|
-
resolved[key] =
|
|
687
|
+
resolved[key] = {
|
|
688
|
+
...(best === null ? { range: dependency.range } : { version: best }),
|
|
689
|
+
...how,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
return resolved;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Everything a version records about itself beyond its hash, tag and date: its
|
|
698
|
+
* dependencies, resolved and each with the entry that declared it, its
|
|
699
|
+
* manifest's `depends` and `subscribes` lists as written, and the variable
|
|
700
|
+
* definitions its manifest publishes. All are recorded even when empty, so a
|
|
701
|
+
* reader can tell "none" from "not recorded".
|
|
702
|
+
*
|
|
703
|
+
* @param recipe - The recipe, with the manifest of the version being recorded.
|
|
704
|
+
* @param declared - What that manifest declares.
|
|
705
|
+
* @param siblings - What each recipe here is published at, for this resolution.
|
|
706
|
+
*/
|
|
707
|
+
function describeVersion(
|
|
708
|
+
recipe: ValidatedRecipe,
|
|
709
|
+
declared: DeclaredDependencies,
|
|
710
|
+
siblings: SiblingState
|
|
711
|
+
): VersionDescription {
|
|
712
|
+
return {
|
|
713
|
+
dependencies: resolveIndexDependencies(declared, siblings),
|
|
714
|
+
depends: (recipe.manifest.depends ?? []).map((written) => written.trim()),
|
|
715
|
+
subscribes: (recipe.manifest.subscribes ?? []).map((written) => written.trim()),
|
|
716
|
+
variables: recipe.manifest.variables ?? [],
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** What `describeVersion` records about a version. */
|
|
721
|
+
type VersionDescription = Pick<
|
|
722
|
+
IndexVersion,
|
|
723
|
+
"dependencies" | "depends" | "subscribes" | "variables"
|
|
724
|
+
>;
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Checks the manifest lists the index records for an already published
|
|
728
|
+
* version against the version's manifest. They are copied, not resolved, so
|
|
729
|
+
* any difference means the index was edited.
|
|
730
|
+
*
|
|
731
|
+
* @param key - The recipe's key, `namespace/recipe`.
|
|
732
|
+
* @param version - The published version.
|
|
733
|
+
* @param published - The version's index entry.
|
|
734
|
+
* @param recipe - The recipe, with the version's manifest.
|
|
735
|
+
*/
|
|
736
|
+
function checkRecordedLists(
|
|
737
|
+
key: string,
|
|
738
|
+
version: string,
|
|
739
|
+
published: IndexVersion,
|
|
740
|
+
recipe: ValidatedRecipe
|
|
741
|
+
): ValidationProblem[] {
|
|
742
|
+
const differences: string[] = [];
|
|
743
|
+
for (const list of ["depends", "subscribes"] as const) {
|
|
744
|
+
const recorded = published[list];
|
|
745
|
+
if (recorded === undefined) continue;
|
|
746
|
+
const declared = (recipe.manifest[list] ?? []).map((written) => written.trim());
|
|
747
|
+
if (JSON.stringify(recorded) === JSON.stringify(declared)) continue;
|
|
748
|
+
differences.push(
|
|
749
|
+
`'${list}': the index records [${recorded.join(", ")}], and the manifest declares ` +
|
|
750
|
+
`[${declared.join(", ")}]`
|
|
751
|
+
);
|
|
610
752
|
}
|
|
611
753
|
|
|
612
|
-
|
|
754
|
+
if (differences.length === 0) return [];
|
|
755
|
+
return [
|
|
756
|
+
{
|
|
757
|
+
level: "error",
|
|
758
|
+
where: `${INDEX_FILENAME} recipes['${key}'].versions['${version}']`,
|
|
759
|
+
message:
|
|
760
|
+
`version ${version} of '${key}' is already published, and the lists the index ` +
|
|
761
|
+
`records for it disagree with its manifest:\n` +
|
|
762
|
+
differences.map((line) => ` - ${line}\n`).join("") +
|
|
763
|
+
` A published version never changes. If the index was edited, restore the entry; ` +
|
|
764
|
+
`to publish different lists, bump the version in this manifest, or with ` +
|
|
765
|
+
`'sous repo release --bump patch'.`,
|
|
766
|
+
},
|
|
767
|
+
];
|
|
613
768
|
}
|
|
614
769
|
|
|
615
770
|
/**
|
|
@@ -643,7 +798,7 @@ function checkRecordedDependencies(
|
|
|
643
798
|
for (const [name, dependency] of declared.byKey) {
|
|
644
799
|
const entry = entries[name];
|
|
645
800
|
if (entry === undefined) {
|
|
646
|
-
if (dependency.
|
|
801
|
+
if (dependency.where === "remote" || dependency.named) {
|
|
647
802
|
differences.push(
|
|
648
803
|
`'${name}': the manifest declares ${describeDeclared(dependency)}, and the index ` +
|
|
649
804
|
`records nothing for it`
|
|
@@ -663,7 +818,7 @@ function checkRecordedDependencies(
|
|
|
663
818
|
if (declared.byKey.has(name)) continue;
|
|
664
819
|
// A recipe the declared namespace held when the version was published, and
|
|
665
820
|
// no longer does, is history rather than a disagreement.
|
|
666
|
-
const namespace =
|
|
821
|
+
const namespace = namespaceOfKey(name);
|
|
667
822
|
if (entry.repo === undefined && declared.namespaces.has(namespace)) continue;
|
|
668
823
|
differences.push(
|
|
669
824
|
`'${name}': the index records ${describeRecorded(entry)}, and the manifest does not ` +
|
|
@@ -690,7 +845,11 @@ function checkRecordedDependencies(
|
|
|
690
845
|
|
|
691
846
|
/** True when a recorded dependency honours what the manifest declares for it. */
|
|
692
847
|
function recordedSatisfies(entry: IndexDependency, dependency: DeclaredDependency): boolean {
|
|
693
|
-
|
|
848
|
+
// How it was declared is recorded only since sous described recipes in the
|
|
849
|
+
// index; an entry that records it has to agree with the manifest too.
|
|
850
|
+
if (entry.declared !== undefined && entry.declared !== dependency.declared) return false;
|
|
851
|
+
if (entry.kind !== undefined && entry.kind !== dependency.kind) return false;
|
|
852
|
+
if (dependency.where === "remote") {
|
|
694
853
|
return (
|
|
695
854
|
entry.repo === dependency.repo &&
|
|
696
855
|
entry.range === dependency.range &&
|
|
@@ -707,23 +866,34 @@ function recordedSatisfies(entry: IndexDependency, dependency: DeclaredDependenc
|
|
|
707
866
|
function describeRecorded(entry: IndexDependency): string {
|
|
708
867
|
const what =
|
|
709
868
|
entry.version !== undefined ? `version ${entry.version}` : `the range '${entry.range}'`;
|
|
710
|
-
|
|
869
|
+
const from = entry.repo === undefined ? what : `${what} from ${entry.repo}`;
|
|
870
|
+
return entry.declared === undefined || entry.kind === undefined
|
|
871
|
+
? from
|
|
872
|
+
: `${from}, through the entry '${entry.declared}' as a ${describeDependencyKind(entry.kind)}`;
|
|
711
873
|
}
|
|
712
874
|
|
|
713
875
|
/** Describes a declared dependency the way an author would say it. */
|
|
714
876
|
function describeDeclared(dependency: DeclaredDependency): string {
|
|
715
|
-
|
|
716
|
-
|
|
877
|
+
const how =
|
|
878
|
+
`, through the entry '${dependency.declared}' as a ` +
|
|
879
|
+
describeDependencyKind(dependency.kind);
|
|
880
|
+
if (dependency.where === "remote") {
|
|
881
|
+
return `the range '${dependency.range}' from ${dependency.repo}${how}`;
|
|
717
882
|
}
|
|
718
|
-
return dependency.range === undefined
|
|
883
|
+
return dependency.range === undefined
|
|
884
|
+
? `it with no range${how}`
|
|
885
|
+
: `the range '${dependency.range}'${how}`;
|
|
719
886
|
}
|
|
720
887
|
|
|
721
888
|
/** What rebuilding one tagged version from its tag produced. */
|
|
722
889
|
type RebuiltVersion = {
|
|
723
890
|
/** The content hash of the recipe folder at the tag. */
|
|
724
891
|
hash: string;
|
|
725
|
-
/**
|
|
726
|
-
|
|
892
|
+
/**
|
|
893
|
+
* Its dependencies and variable definitions when the recipe could be read at
|
|
894
|
+
* the tag, and nothing when it could not; see `describeVersion`.
|
|
895
|
+
*/
|
|
896
|
+
description: VersionDescription;
|
|
727
897
|
/** A warning when they could not. */
|
|
728
898
|
problems: ValidationProblem[];
|
|
729
899
|
};
|
|
@@ -741,6 +911,7 @@ type RebuiltVersion = {
|
|
|
741
911
|
* @param tagsByKey - Every release tag, grouped by recipe.
|
|
742
912
|
* @param existing - The committed index, when there is one.
|
|
743
913
|
* @param run - The command runner git calls go through.
|
|
914
|
+
* @param settled - The dependencies that read more than one way, as the release settled them.
|
|
744
915
|
*/
|
|
745
916
|
async function rebuildTaggedVersion(
|
|
746
917
|
rootDir: string,
|
|
@@ -748,7 +919,8 @@ async function rebuildTaggedVersion(
|
|
|
748
919
|
recipe: ValidatedRecipe,
|
|
749
920
|
tagsByKey: Map<string, RecipeTag[]>,
|
|
750
921
|
existing: IndexFile | undefined,
|
|
751
|
-
run: RunOptions["run"]
|
|
922
|
+
run: RunOptions["run"],
|
|
923
|
+
settled?: Map<string, SettledDependency>
|
|
752
924
|
): Promise<RebuiltVersion> {
|
|
753
925
|
return withTaggedTree(
|
|
754
926
|
rootDir,
|
|
@@ -762,11 +934,12 @@ async function rebuildTaggedVersion(
|
|
|
762
934
|
const snapshot = validateRepo(treeDir);
|
|
763
935
|
const tagged = snapshot.recipes.find((entry) => entry.key === recipe.key);
|
|
764
936
|
if (tagged !== undefined) {
|
|
765
|
-
const
|
|
766
|
-
|
|
937
|
+
const description = describeVersion(
|
|
938
|
+
tagged,
|
|
939
|
+
declaredDependencies(tagged, snapshot, settled),
|
|
767
940
|
siblingState(snapshot, tagsByKey, existing)
|
|
768
941
|
);
|
|
769
|
-
return { hash,
|
|
942
|
+
return { hash, description, problems: [] };
|
|
770
943
|
}
|
|
771
944
|
reason = `The repository manifest at that tag does not list '${recipe.key}'.`;
|
|
772
945
|
} catch (error) {
|
|
@@ -775,6 +948,7 @@ async function rebuildTaggedVersion(
|
|
|
775
948
|
|
|
776
949
|
return {
|
|
777
950
|
hash,
|
|
951
|
+
description: {},
|
|
778
952
|
problems: [
|
|
779
953
|
{
|
|
780
954
|
level: "warning",
|
|
@@ -11,6 +11,7 @@ export * from "./validate.js";
|
|
|
11
11
|
export * from "./tags.js";
|
|
12
12
|
export * from "./git-state.js";
|
|
13
13
|
export * from "./index-builder.js";
|
|
14
|
+
export * from "./settle.js";
|
|
14
15
|
export * from "./plan.js";
|
|
15
16
|
export * from "./bump.js";
|
|
16
17
|
export * from "./submit-service.js";
|