@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.
Files changed (52) hide show
  1. package/docs/markdown/_sidebar.md +2 -0
  2. package/docs/markdown/commands.md +10 -6
  3. package/docs/markdown/repositories-authoring.md +19 -11
  4. package/docs/markdown/repositories-consuming.md +34 -13
  5. package/docs/markdown/repositories-file-formats.md +88 -13
  6. package/docs/markdown/repositories-providers.md +8 -6
  7. package/docs/markdown/repositories-troubleshooting.md +6 -0
  8. package/docs/markdown/repositories-variables.md +13 -3
  9. package/docs/markdown/repositories.md +3 -3
  10. package/package.json +1 -1
  11. package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +11 -7
  12. package/recipes/core/sous-skills/skills/about-sous-repos/SKILL.tpl.md +7 -4
  13. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  14. package/src/commands/lock/rebuild.ts +7 -6
  15. package/src/commands/recipe/show.ts +163 -64
  16. package/src/commands/repo/contribute.ts +1 -10
  17. package/src/commands/repo/gc.ts +3 -2
  18. package/src/commands/repo/release.ts +20 -3
  19. package/src/commands/repo/search.ts +2 -1
  20. package/src/commands/vars/ask.ts +32 -10
  21. package/src/lib/refs/find.ts +202 -21
  22. package/src/lib/refs/index.ts +2 -0
  23. package/src/lib/refs/parse.ts +844 -0
  24. package/src/lib/refs/scopes.ts +34 -0
  25. package/src/lib/refs/settle.ts +219 -0
  26. package/src/lib/repos/catalog.ts +87 -80
  27. package/src/lib/repos/declarations.ts +177 -0
  28. package/src/lib/repos/formats/common.ts +14 -0
  29. package/src/lib/repos/formats/index-file.ts +42 -2
  30. package/src/lib/repos/formats/lockfile.ts +5 -3
  31. package/src/lib/repos/formats/recipe-manifest.ts +16 -212
  32. package/src/lib/repos/formats/variable-definition.ts +268 -0
  33. package/src/lib/repos/index.ts +0 -1
  34. package/src/lib/repos/lock-service.ts +3 -2
  35. package/src/lib/repos/locked-namespace-resolver.ts +21 -15
  36. package/src/lib/repos/locked-recipes.ts +2 -2
  37. package/src/lib/repos/namespace-resolver.ts +40 -47
  38. package/src/lib/repos/providers/base.ts +30 -0
  39. package/src/lib/repos/providers/github.ts +22 -0
  40. package/src/lib/repos/providers/gitlab.ts +68 -0
  41. package/src/lib/repos/providers/provider.ts +96 -0
  42. package/src/lib/repos/release/index-builder.ts +221 -47
  43. package/src/lib/repos/release/index.ts +1 -0
  44. package/src/lib/repos/release/plan.ts +8 -6
  45. package/src/lib/repos/release/settle.ts +237 -0
  46. package/src/lib/repos/release/tags.ts +3 -7
  47. package/src/lib/repos/resolver.ts +251 -53
  48. package/src/lib/repos/subscription-service.ts +355 -49
  49. package/src/lib/repos/update-plan.ts +3 -2
  50. package/src/lib/vars/mappings.ts +17 -30
  51. package/src/lib/vars/question-plan.ts +44 -21
  52. 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 { parseDependencyRef } from "../ref.js";
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(rootDir, tag, recipe, tagsByKey, existing, run);
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
- ...(rebuilt.dependencies === undefined ? {} : { dependencies: rebuilt.dependencies }),
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
- ...(dependencies === undefined ? {} : { dependencies }),
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 DeclaredDependency =
455
+ type DeclaredLocation =
423
456
  | {
424
457
  /** A recipe in another repository, named by its location. */
425
- kind: "remote";
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
- kind: "sibling";
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, DeclaredDependency>();
510
+ const byKey = new Map<string, DeclaredLocation>();
473
511
  const namespaces = new Set<string>();
474
- const declared = [
475
- ...(recipe.manifest.depends ?? []),
476
- ...(recipe.manifest.subscribes ?? []),
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?.kind === "sibling" && before.named;
528
+ const namedBefore = before?.where === "sibling" && before.named;
483
529
  byKey.set(key, {
484
- kind: "sibling",
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
- let parsed;
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
- parsed = parseDependencyRef(written);
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
- if (parsed.kind === "remote") {
500
- byKey.set(`${parsed.namespace}/${parsed.recipe}`, {
501
- kind: "remote",
502
- repo: parsed.canonicalRepo!,
503
- range: parsed.range ?? "*",
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(`${parsed.namespace}/${parsed.recipe}`, parsed.range, true);
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
- return { byKey, namespaces };
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> | undefined {
669
+ ): Record<string, IndexDependency> {
596
670
  const resolved: Record<string, IndexDependency> = {};
597
671
 
598
672
  for (const [key, dependency] of declared.byKey) {
599
- if (dependency.kind === "remote") {
600
- resolved[key] = { repo: dependency.repo, range: dependency.range };
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] = settled === undefined ? { range: "*" } : { version: settled };
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] = best === null ? { range: dependency.range } : { version: best };
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
- return Object.keys(resolved).length === 0 ? undefined : resolved;
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.kind === "remote" || dependency.named) {
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 = name.slice(0, name.indexOf("/"));
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
- if (dependency.kind === "remote") {
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
- return entry.repo === undefined ? what : `${what} from ${entry.repo}`;
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
- if (dependency.kind === "remote") {
716
- return `the range '${dependency.range}' from ${dependency.repo}`;
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 ? "it with no range" : `the range '${dependency.range}'`;
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
- /** The dependencies it was released against, when they could be worked out. */
726
- dependencies?: Record<string, IndexDependency>;
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 dependencies = resolveIndexDependencies(
766
- declaredDependencies(tagged, snapshot),
937
+ const description = describeVersion(
938
+ tagged,
939
+ declaredDependencies(tagged, snapshot, settled),
767
940
  siblingState(snapshot, tagsByKey, existing)
768
941
  );
769
- return { hash, ...(dependencies === undefined ? {} : { dependencies }), problems: [] };
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";