@sous-io/sous 0.2.29 → 0.2.31

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 (37) hide show
  1. package/README.md +12 -0
  2. package/docs/markdown/README.md +52 -0
  3. package/docs/markdown/_sidebar.md +2 -0
  4. package/docs/markdown/commands.md +31 -6
  5. package/docs/markdown/repositories-authoring.md +7 -3
  6. package/docs/markdown/repositories-consuming.md +26 -12
  7. package/docs/markdown/repositories-file-formats.md +35 -5
  8. package/docs/markdown/repositories-variables.md +4 -1
  9. package/package.json +2 -1
  10. package/recipes/core/sous-skills/skills/about-sous-repos/SKILL.tpl.md +7 -4
  11. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  12. package/src/base-command.ts +75 -2
  13. package/src/commands/lock/rebuild.ts +5 -4
  14. package/src/commands/recipe/show.ts +163 -64
  15. package/src/commands/update.ts +178 -0
  16. package/src/lib/project-install.d.mts +4 -0
  17. package/src/lib/project-install.mjs +46 -8
  18. package/src/lib/repos/catalog.ts +43 -55
  19. package/src/lib/repos/declarations.ts +177 -0
  20. package/src/lib/repos/formats/common.ts +14 -0
  21. package/src/lib/repos/formats/index-file.ts +40 -1
  22. package/src/lib/repos/formats/lockfile.ts +5 -3
  23. package/src/lib/repos/formats/recipe-manifest.ts +6 -204
  24. package/src/lib/repos/formats/variable-definition.ts +268 -0
  25. package/src/lib/repos/providers/http.ts +37 -16
  26. package/src/lib/repos/release/index-builder.ts +168 -32
  27. package/src/lib/repos/resolver.ts +53 -12
  28. package/src/lib/repos/subscription-service.ts +93 -11
  29. package/src/lib/repos/update-plan.ts +3 -2
  30. package/src/lib/self-update/index.ts +14 -0
  31. package/src/lib/self-update/installs.ts +412 -0
  32. package/src/lib/self-update/managers.ts +126 -0
  33. package/src/lib/self-update/plan.ts +315 -0
  34. package/src/lib/self-update/registry.ts +99 -0
  35. package/src/lib/self-update/run.ts +506 -0
  36. package/src/lib/self-update/versions.ts +243 -0
  37. package/src/lib/vars/question-plan.ts +44 -21
@@ -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,6 +67,14 @@ import {
60
67
  type ValidatedRecipe,
61
68
  type ValidationProblem,
62
69
  } from "./validate.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";
63
78
  import { isNamedReading, namespaceOfKey, parseRef, refKey } from "../../refs/parse.js";
64
79
  import { RefSource } from "../../refs/scopes.js";
65
80
  import type { SettledDependency } from "./settle.js";
@@ -204,7 +219,7 @@ export async function buildIndex(
204
219
  tag: tag.tag,
205
220
  prerelease: semver.prerelease(tag.version) !== null,
206
221
  ...(await releasedAtOf(rootDir, tag.tag, run)),
207
- ...(rebuilt.dependencies === undefined ? {} : { dependencies: rebuilt.dependencies }),
222
+ ...rebuilt.description,
208
223
  };
209
224
  }
210
225
 
@@ -265,6 +280,9 @@ export async function buildIndex(
265
280
  ...checkRecordedDependencies(key, version, published.dependencies, declared)
266
281
  );
267
282
  }
283
+ if (taggedHash === workingHash) {
284
+ problems.push(...checkRecordedLists(key, version, published, recipe));
285
+ }
268
286
  }
269
287
  // A tagged version the index did not record was rebuilt from its tag above.
270
288
 
@@ -294,13 +312,12 @@ export async function buildIndex(
294
312
  // follows on the very commit that carries this index.
295
313
  const published = existingVersions[version];
296
314
  if (options.publishing?.[key] === version || published !== undefined) {
297
- const dependencies = resolveIndexDependencies(declared, current);
298
315
  versions[version] = {
299
316
  hash: workingHash,
300
317
  tag: tagName,
301
318
  prerelease: semver.prerelease(version) !== null,
302
319
  releasedAt: published?.releasedAt ?? now.toISOString(),
303
- ...(dependencies === undefined ? {} : { dependencies }),
320
+ ...describeVersion(recipe, declared, current),
304
321
  };
305
322
  }
306
323
 
@@ -435,10 +452,10 @@ export function describeIndexDrift(
435
452
  * `DeclaredDependencies`. A whole-namespace declaration is expanded into one
436
453
  * entry per recipe the namespace holds.
437
454
  */
438
- type DeclaredDependency =
455
+ type DeclaredLocation =
439
456
  | {
440
457
  /** A recipe in another repository, named by its location. */
441
- kind: "remote";
458
+ where: "remote";
442
459
  /** The canonical identity of the repository publishing it. */
443
460
  repo: string;
444
461
  /** The range the manifest declared, `*` when it declared none. */
@@ -446,7 +463,7 @@ type DeclaredDependency =
446
463
  }
447
464
  | {
448
465
  /** A recipe in this same repository. */
449
- kind: "sibling";
466
+ where: "sibling";
450
467
  /** The range the manifest declared, when it declared one. */
451
468
  range?: string;
452
469
  /**
@@ -458,6 +475,9 @@ type DeclaredDependency =
458
475
  named: boolean;
459
476
  };
460
477
 
478
+ /** One declared dependency: where it lives, and the manifest entry bringing it in. */
479
+ type DeclaredDependency = DeclaredLocation & DependencyDeclaration;
480
+
461
481
  /** Everything one recipe's manifest declares, under `depends` and `subscribes`. */
462
482
  type DeclaredDependencies = {
463
483
  /** Every dependency, keyed `namespace/recipe`; a later declaration wins over an earlier one. */
@@ -487,31 +507,40 @@ function declaredDependencies(
487
507
  validation: RepoValidation,
488
508
  settled: Map<string, SettledDependency> = new Map()
489
509
  ): DeclaredDependencies {
490
- const byKey = new Map<string, DeclaredDependency>();
510
+ const byKey = new Map<string, DeclaredLocation>();
491
511
  const namespaces = new Set<string>();
492
- const declared = [
493
- ...(recipe.manifest.depends ?? []),
494
- ...(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),
495
518
  ];
496
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
+
497
525
  /** Records one sibling, remembering whether any declaration named it. */
498
526
  const addSibling = (key: string, range: string | undefined, named: boolean): void => {
499
527
  const before = byKey.get(key);
500
- const namedBefore = before?.kind === "sibling" && before.named;
528
+ const namedBefore = before?.where === "sibling" && before.named;
501
529
  byKey.set(key, {
502
- kind: "sibling",
530
+ where: "sibling",
503
531
  ...(range === undefined ? {} : { range }),
504
532
  named: named || namedBefore,
505
533
  });
506
534
  };
507
535
 
508
- for (const written of declared) {
536
+ for (const [kind, written] of declared) {
509
537
  // A dependency that reads more than one way was settled by the release,
510
538
  // and what it settled on is what a consumer reads instead of probing.
511
539
  const answer = settled.get(written.trim());
512
540
  if (answer !== undefined) {
513
541
  for (const key of answer.keys) {
514
- byKey.set(key, { kind: "remote", repo: answer.identity, range: answer.range ?? "*" });
542
+ byKey.set(key, { where: "remote", repo: answer.identity, range: answer.range ?? "*" });
543
+ cover(key, { written, kind, named: answer.keys.length === 1 });
515
544
  }
516
545
  continue;
517
546
  }
@@ -533,16 +562,18 @@ function declaredDependencies(
533
562
  // own index by the consumer; only a recipe has a key to record.
534
563
  if (parsed.recipe !== undefined) {
535
564
  byKey.set(refKey(parsed), {
536
- kind: "remote",
565
+ where: "remote",
537
566
  repo: parsed.location.identity,
538
567
  range: parsed.range ?? "*",
539
568
  });
569
+ cover(refKey(parsed), { written, kind, named: true });
540
570
  }
541
571
  continue;
542
572
  }
543
573
 
544
574
  if (parsed.recipe !== undefined) {
545
575
  addSibling(refKey(parsed), parsed.range, true);
576
+ cover(refKey(parsed), { written, kind, named: true });
546
577
  continue;
547
578
  }
548
579
 
@@ -552,10 +583,17 @@ function declaredDependencies(
552
583
  if (entry.manifest.namespace !== parsed.namespace) continue;
553
584
  if (entry.key === recipe.key) continue;
554
585
  addSibling(entry.key, undefined, false);
586
+ cover(entry.key, { written, kind, named: false });
555
587
  }
556
588
  }
557
589
 
558
- 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 };
559
597
  }
560
598
 
561
599
  /**
@@ -628,24 +666,105 @@ function siblingState(
628
666
  function resolveIndexDependencies(
629
667
  declared: DeclaredDependencies,
630
668
  siblings: SiblingState
631
- ): Record<string, IndexDependency> | undefined {
669
+ ): Record<string, IndexDependency> {
632
670
  const resolved: Record<string, IndexDependency> = {};
633
671
 
634
672
  for (const [key, dependency] of declared.byKey) {
635
- if (dependency.kind === "remote") {
636
- 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 };
637
676
  continue;
638
677
  }
639
678
  if (dependency.range === undefined) {
640
679
  const settled = siblings.settled.get(key);
641
- resolved[key] = settled === undefined ? { range: "*" } : { version: settled };
680
+ resolved[key] = {
681
+ ...(settled === undefined ? { range: "*" } : { version: settled }),
682
+ ...how,
683
+ };
642
684
  continue;
643
685
  }
644
686
  const best = semver.maxSatisfying(siblings.published.get(key) ?? [], dependency.range);
645
- resolved[key] = best === null ? { range: dependency.range } : { version: best };
687
+ resolved[key] = {
688
+ ...(best === null ? { range: dependency.range } : { version: best }),
689
+ ...how,
690
+ };
646
691
  }
647
692
 
648
- return Object.keys(resolved).length === 0 ? undefined : resolved;
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
+ );
752
+ }
753
+
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
+ ];
649
768
  }
650
769
 
651
770
  /**
@@ -679,7 +798,7 @@ function checkRecordedDependencies(
679
798
  for (const [name, dependency] of declared.byKey) {
680
799
  const entry = entries[name];
681
800
  if (entry === undefined) {
682
- if (dependency.kind === "remote" || dependency.named) {
801
+ if (dependency.where === "remote" || dependency.named) {
683
802
  differences.push(
684
803
  `'${name}': the manifest declares ${describeDeclared(dependency)}, and the index ` +
685
804
  `records nothing for it`
@@ -726,7 +845,11 @@ function checkRecordedDependencies(
726
845
 
727
846
  /** True when a recorded dependency honours what the manifest declares for it. */
728
847
  function recordedSatisfies(entry: IndexDependency, dependency: DeclaredDependency): boolean {
729
- 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") {
730
853
  return (
731
854
  entry.repo === dependency.repo &&
732
855
  entry.range === dependency.range &&
@@ -743,23 +866,34 @@ function recordedSatisfies(entry: IndexDependency, dependency: DeclaredDependenc
743
866
  function describeRecorded(entry: IndexDependency): string {
744
867
  const what =
745
868
  entry.version !== undefined ? `version ${entry.version}` : `the range '${entry.range}'`;
746
- 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)}`;
747
873
  }
748
874
 
749
875
  /** Describes a declared dependency the way an author would say it. */
750
876
  function describeDeclared(dependency: DeclaredDependency): string {
751
- if (dependency.kind === "remote") {
752
- 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}`;
753
882
  }
754
- 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}`;
755
886
  }
756
887
 
757
888
  /** What rebuilding one tagged version from its tag produced. */
758
889
  type RebuiltVersion = {
759
890
  /** The content hash of the recipe folder at the tag. */
760
891
  hash: string;
761
- /** The dependencies it was released against, when they could be worked out. */
762
- 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;
763
897
  /** A warning when they could not. */
764
898
  problems: ValidationProblem[];
765
899
  };
@@ -800,11 +934,12 @@ async function rebuildTaggedVersion(
800
934
  const snapshot = validateRepo(treeDir);
801
935
  const tagged = snapshot.recipes.find((entry) => entry.key === recipe.key);
802
936
  if (tagged !== undefined) {
803
- const dependencies = resolveIndexDependencies(
937
+ const description = describeVersion(
938
+ tagged,
804
939
  declaredDependencies(tagged, snapshot, settled),
805
940
  siblingState(snapshot, tagsByKey, existing)
806
941
  );
807
- return { hash, ...(dependencies === undefined ? {} : { dependencies }), problems: [] };
942
+ return { hash, description, problems: [] };
808
943
  }
809
944
  reason = `The repository manifest at that tag does not list '${recipe.key}'.`;
810
945
  } catch (error) {
@@ -813,6 +948,7 @@ async function rebuildTaggedVersion(
813
948
 
814
949
  return {
815
950
  hash,
951
+ description: {},
816
952
  problems: [
817
953
  {
818
954
  level: "warning",
@@ -13,7 +13,10 @@
13
13
  * project) and `subscribes` (co-subscribed with full semantics), and those refs
14
14
  * resolve the same way. The manifest lives inside the recipe's own files, so
15
15
  * the caller supplies a loader; that is also the seam where a caller decides
16
- * whether it is willing to fetch anything at this point.
16
+ * whether it is willing to fetch anything at this point. When the loader has no
17
+ * manifest to give, the lists are read from the version's index entry instead,
18
+ * which records the entry that declared each dependency; only a version whose
19
+ * entry predates that record goes unwalked.
17
20
  *
18
21
  * Two things the resolver deliberately does NOT do: it never downloads
19
22
  * anything, and it never resolves against a repository the project has not
@@ -25,7 +28,8 @@ import semver from "semver";
25
28
  import { ConfigError } from "../errors.js";
26
29
  import type { IndexFile } from "./formats/index-file.js";
27
30
  import type { LockKind } from "./formats/lockfile.js";
28
- import type { RecipeManifest } from "./formats/recipe-manifest.js";
31
+ import type { RecipeManifest, VariableDefinition } from "./formats/recipe-manifest.js";
32
+ import { indexDependencyLists, type DependencyLists } from "./declarations.js";
29
33
  import {
30
34
  formatRef,
31
35
  isBrowsedReading,
@@ -81,8 +85,9 @@ export type ResolverRepo = {
81
85
 
82
86
  /**
83
87
  * Loads a resolved recipe's manifest, which is what names its dependencies.
84
- * Returning undefined means "not available", and the recipe is reported under
85
- * `missingManifests` rather than having its dependencies walked.
88
+ * Returning undefined means "not available": the recipe's dependencies are then
89
+ * read from its index entry, and when that does not record them either, it is
90
+ * reported under `missingManifests` rather than having its dependencies walked.
86
91
  */
87
92
  export type RecipeManifestLoader = (
88
93
  recipe: ResolvedRecipe
@@ -141,6 +146,17 @@ export type ResolvedRecipe = {
141
146
  * published against rather than at whatever its range would reach today.
142
147
  */
143
148
  dependencies?: Record<string, IndexDependency>;
149
+ /**
150
+ * The variable definitions the index records for this exact version, when it
151
+ * records them: the questions it asks, known before its files are fetched.
152
+ */
153
+ variables?: VariableDefinition[];
154
+ /**
155
+ * The manifest `depends` and `subscribes` lists the index records for this
156
+ * exact version, when it records them, so the recipe can be walked before its
157
+ * files are fetched.
158
+ */
159
+ declares?: DependencyLists;
144
160
  };
145
161
 
146
162
  /** A repository something needs that the project has not added. */
@@ -166,7 +182,10 @@ export type ResolveResult = {
166
182
  resolved: ResolvedRecipe[];
167
183
  /** Repositories a dependency needs that the project has not added. */
168
184
  missingRepos: MissingRepo[];
169
- /** Resolved recipes whose manifest the loader could not produce. */
185
+ /**
186
+ * Resolved recipes whose dependencies could not be read: the loader produced
187
+ * no manifest, and the index entry does not record them.
188
+ */
170
189
  missingManifests: string[];
171
190
  /** Any dependency cycle found, as the chain of recipe keys forming it. */
172
191
  cycles: string[][];
@@ -495,15 +514,15 @@ export async function resolveRefs(
495
514
  }
496
515
  walked.set(recipe.key, recipe.version);
497
516
 
498
- const manifest = await context.loadManifest(recipe);
499
- if (manifest === undefined) {
517
+ const lists = dependencyListsOf(recipe, await context.loadManifest(recipe));
518
+ if (lists === undefined) {
500
519
  missingManifests.add(recipe.key);
501
520
  continue;
502
521
  }
503
522
 
504
523
  for (const [kind, refs] of [
505
- ["depends", manifest.depends ?? []],
506
- ["subscribes", manifest.subscribes ?? []],
524
+ ["depends", lists.depends],
525
+ ["subscribes", lists.subscribes],
507
526
  ] as Array<[LockKind, string[]]>) {
508
527
  for (const written of refs) {
509
528
  queue.push(dependencyRequest(written, recipe, kind, context));
@@ -593,8 +612,9 @@ async function keepOnlyReachable(
593
612
  } catch {
594
613
  manifest = undefined;
595
614
  }
615
+ const lists = dependencyListsOf(recipe, manifest);
596
616
 
597
- if (manifest === undefined) {
617
+ if (lists === undefined) {
598
618
  // An unreadable manifest is already reported as a missing manifest. Keep
599
619
  // everything this recipe held rather than dropping a dependency over it.
600
620
  for (const other of resolved.values()) {
@@ -606,8 +626,8 @@ async function keepOnlyReachable(
606
626
  }
607
627
 
608
628
  for (const [kind, refs] of [
609
- ["depends", manifest.depends ?? []],
610
- ["subscribes", manifest.subscribes ?? []],
629
+ ["depends", lists.depends],
630
+ ["subscribes", lists.subscribes],
611
631
  ] as Array<[LockKind, string[]]>) {
612
632
  for (const written of refs) {
613
633
  for (const target of dependencyTargets(written, recipe, context, resolved)) {
@@ -663,6 +683,24 @@ function dependencyTargets(
663
683
  : [refKey(item.ref)];
664
684
  }
665
685
 
686
+ /**
687
+ * What a resolved recipe declares under `depends` and `subscribes`: its
688
+ * manifest's lists when the manifest could be read, and otherwise the lists its
689
+ * index entry records, or undefined when neither says.
690
+ *
691
+ * @param recipe - The resolved recipe, carrying the lists its index entry records.
692
+ * @param manifest - Its manifest, when the loader produced one.
693
+ */
694
+ function dependencyListsOf(
695
+ recipe: ResolvedRecipe,
696
+ manifest: RecipeManifest | undefined
697
+ ): DependencyLists | undefined {
698
+ if (manifest !== undefined) {
699
+ return { depends: manifest.depends ?? [], subscribes: manifest.subscribes ?? [] };
700
+ }
701
+ return recipe.declares;
702
+ }
703
+
666
704
  /** Records one more reason a repository is needed. */
667
705
  function recordMissingRepo(into: Map<string, MissingRepo>, missing: MissingRepo): void {
668
706
  const existing = into.get(missing.name);
@@ -775,6 +813,7 @@ function resolveRecipeRef(
775
813
 
776
814
  const version = pickVersion(key, entry, ranges, prerelease, context.keep?.[key]);
777
815
  const versionEntry = entry.versions[version]!;
816
+ const declares = indexDependencyLists(versionEntry);
778
817
 
779
818
  const requestedBy = [...(previous?.requestedBy ?? [])];
780
819
  if (!requestedBy.includes(item.requestedBy)) requestedBy.push(item.requestedBy);
@@ -793,6 +832,8 @@ function resolveRecipeRef(
793
832
  ...(versionEntry.dependencies === undefined
794
833
  ? {}
795
834
  : { dependencies: versionEntry.dependencies }),
835
+ ...(versionEntry.variables === undefined ? {} : { variables: versionEntry.variables }),
836
+ ...(declares === undefined ? {} : { declares }),
796
837
  // A recipe held as a co-subscription by anyone is a co-subscription; a
797
838
  // build dependency only stays one while nothing subscribes to it.
798
839
  kind: previous?.kind === "subscribes" || item.kind === "subscribes" ? "subscribes" : "depends",
@@ -56,7 +56,7 @@ import {
56
56
  type LockedRecipe,
57
57
  type Lockfile,
58
58
  } from "./formats/lockfile.js";
59
- import type { RecipeManifest } from "./formats/recipe-manifest.js";
59
+ import type { RecipeManifest, VariableDefinition } from "./formats/recipe-manifest.js";
60
60
  import {
61
61
  RefSource,
62
62
  Qualification,
@@ -292,9 +292,9 @@ export type SubscribeOutcome = {
292
292
  */
293
293
  questions?: PlannedVariable[];
294
294
  /**
295
- * Recipes a dry run could not describe, because their files are not on this
296
- * machine and a dry run downloads nothing. Their questions are unknown until
297
- * they are installed.
295
+ * Recipes a dry run could not describe: their files are not on this machine,
296
+ * a dry run downloads nothing, and their repository's index does not record
297
+ * their questions. Their questions are unknown until they are installed.
298
298
  */
299
299
  unreadable?: string[];
300
300
  /** Dependency cycles the resolver noticed, reported rather than treated as fatal. */
@@ -303,6 +303,28 @@ export type SubscribeOutcome = {
303
303
  dryRun: boolean;
304
304
  };
305
305
 
306
+ /** What `previewSubscription` is asked about. */
307
+ export type PreviewSubscriptionOptions = {
308
+ /** The indexes to resolve against, keyed by short name; the cached ones by default. */
309
+ indexes?: Map<string, IndexFile>;
310
+ /** Whether prerelease versions may take part in range matching. */
311
+ prerelease?: boolean;
312
+ };
313
+
314
+ /** What subscribing to a ref would install and ask, worked out without changing anything. */
315
+ export type SubscriptionPreview = {
316
+ /** Every recipe version the subscription would install, sorted by key. */
317
+ resolved: ResolvedRecipe[];
318
+ /** Repositories the closure needs that the project does not trust yet. */
319
+ missingRepos: MissingRepo[];
320
+ /** Every question the closure would ask, and where each answer would go. */
321
+ questions: PlannedVariable[];
322
+ /** Recipes whose questions cannot be listed; see `SubscribeOutcome.unreadable`. */
323
+ unreadable: string[];
324
+ /** Dependency cycles the resolver noticed. */
325
+ cycles: string[][];
326
+ };
327
+
306
328
  /** What `unsubscribe` is asked to do. */
307
329
  export type UnsubscribeOptions = {
308
330
  /** The ref to unsubscribe from. */
@@ -3529,9 +3551,10 @@ export class SubscriptionService {
3529
3551
  * Every variable definition the resolved closure publishes, attributed to the
3530
3552
  * recipe that declared it and to the chain that pulled it in.
3531
3553
  *
3532
- * A recipe whose files are not on this machine contributes nothing rather
3533
- * than failing, which is what lets a dry run describe as much of the closure
3534
- * as it can without downloading any of it.
3554
+ * A recipe whose files are not on this machine is described from the
3555
+ * definitions its index entry records, and contributes nothing rather than
3556
+ * failing when the entry records none, which is what lets a dry run describe
3557
+ * as much of the closure as it can without downloading any of it.
3535
3558
  *
3536
3559
  * @param resolved - The recipe versions the resolution settled on.
3537
3560
  */
@@ -3581,12 +3604,12 @@ export class SubscriptionService {
3581
3604
  });
3582
3605
 
3583
3606
  for (const recipe of ordered) {
3584
- const manifest = readRecipeManifestIn(this.recipeDirectory(recipe));
3585
- if (manifest === undefined) continue;
3607
+ const variables = this.publishedVariables(recipe);
3608
+ if (variables === undefined) continue;
3586
3609
 
3587
3610
  const publisher = describe(recipe);
3588
3611
  const requiredBy = chainFor(recipe).map(describe);
3589
- for (const definition of manifest.variables ?? []) {
3612
+ for (const definition of variables) {
3590
3613
  defined.push({ definition, recipe: publisher, requiredBy });
3591
3614
  }
3592
3615
  }
@@ -3604,11 +3627,70 @@ export class SubscriptionService {
3604
3627
  */
3605
3628
  private unreadableRecipes(resolved: ResolvedRecipe[]): string[] {
3606
3629
  return resolved
3607
- .filter((recipe) => readRecipeManifestIn(this.recipeDirectory(recipe)) === undefined)
3630
+ .filter((recipe) => this.publishedVariables(recipe) === undefined)
3608
3631
  .map((recipe) => recipe.key)
3609
3632
  .sort();
3610
3633
  }
3611
3634
 
3635
+ /**
3636
+ * The variable definitions one resolved recipe publishes: from its manifest
3637
+ * when its files are on this machine (a linked checkout's working copy
3638
+ * included), otherwise from its index entry, or undefined when neither can
3639
+ * say.
3640
+ *
3641
+ * @param recipe - The resolved recipe.
3642
+ */
3643
+ private publishedVariables(recipe: ResolvedRecipe): VariableDefinition[] | undefined {
3644
+ const manifest = readRecipeManifestIn(this.recipeDirectory(recipe));
3645
+ if (manifest !== undefined) return manifest.variables ?? [];
3646
+ return recipe.variables;
3647
+ }
3648
+
3649
+ /**
3650
+ * Works out what subscribing to a ref would install and which questions it
3651
+ * would ask, from the indexes and the files already on this machine. Nothing
3652
+ * is fetched, trusted or written, which is what lets `sous recipe show`
3653
+ * describe a recipe before anyone subscribes to it. A repository the closure
3654
+ * needs and the project does not trust is reported, and its recipes are not
3655
+ * resolved.
3656
+ *
3657
+ * @param parsed - The fully qualified ref, with the exact version to describe as its range.
3658
+ * @param options - The indexes to read, and whether prereleases may match.
3659
+ */
3660
+ async previewSubscription(
3661
+ parsed: ParsedRef,
3662
+ options: PreviewSubscriptionOptions = {}
3663
+ ): Promise<SubscriptionPreview> {
3664
+ const repos = this.resolverRepos();
3665
+ const indexes = options.indexes ?? (await this.loadIndexes(Object.keys(repos)));
3666
+ const result = await resolveRefs(
3667
+ [
3668
+ {
3669
+ ref: parsed,
3670
+ requestedBy: PROJECT_REQUESTER,
3671
+ kind: "subscribes",
3672
+ ...(options.prerelease === true ? { prerelease: true } : {}),
3673
+ },
3674
+ ],
3675
+ {
3676
+ indexes,
3677
+ repos,
3678
+ loadManifest: (recipe) => this.loadRecipeManifest(recipe, true),
3679
+ ...(options.prerelease === true ? { prerelease: true } : {}),
3680
+ }
3681
+ );
3682
+
3683
+ return {
3684
+ resolved: result.resolved,
3685
+ missingRepos: result.missingRepos,
3686
+ questions: planQuestions(this.definedVariables(result.resolved), this.ladderContext(), {
3687
+ sousDir: this.sousDir,
3688
+ }),
3689
+ unreadable: this.unreadableRecipes(result.resolved),
3690
+ cycles: result.cycles,
3691
+ };
3692
+ }
3693
+
3612
3694
  /** The environment layers and mapping records this project resolves against. */
3613
3695
  private ladderContext(): LadderContext {
3614
3696
  return loadLadderContext({