@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
@@ -56,20 +56,33 @@ import {
56
56
  type LockedRecipe,
57
57
  type Lockfile,
58
58
  } from "./formats/lockfile.js";
59
- import type { RecipeManifest } from "./formats/recipe-manifest.js";
60
- import { formatRef, parseRef, refKey, type ParsedRef } from "./ref.js";
59
+ import type { RecipeManifest, VariableDefinition } from "./formats/recipe-manifest.js";
61
60
  import {
61
+ RefSource,
62
+ Qualification,
62
63
  SousScope,
64
+ formatRef,
65
+ isNamedReading,
66
+ matchNames,
67
+ namespaceOfKey,
68
+ parseRef,
69
+ parseShortRef,
70
+ refKey,
71
+ splitRecipeKey,
72
+ type ParsedRef,
73
+ type RefReading,
63
74
  describeReference,
64
75
  findReference,
65
76
  pickReference,
66
77
  referenceReposFromIndexes,
67
78
  referenceToRef,
68
79
  type ReferenceMatch,
80
+ type ReferenceRepo,
69
81
  } from "../refs/index.js";
70
82
  import { describeIndexSearch } from "./ref-search.js";
71
83
  import {
72
84
  PROJECT_REQUESTER,
85
+ proposeRepoName,
73
86
  resolveRefs,
74
87
  type MissingRepo,
75
88
  type RefRequest,
@@ -279,9 +292,9 @@ export type SubscribeOutcome = {
279
292
  */
280
293
  questions?: PlannedVariable[];
281
294
  /**
282
- * Recipes a dry run could not describe, because their files are not on this
283
- * machine and a dry run downloads nothing. Their questions are unknown until
284
- * 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.
285
298
  */
286
299
  unreadable?: string[];
287
300
  /** Dependency cycles the resolver noticed, reported rather than treated as fatal. */
@@ -290,6 +303,28 @@ export type SubscribeOutcome = {
290
303
  dryRun: boolean;
291
304
  };
292
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
+
293
328
  /** What `unsubscribe` is asked to do. */
294
329
  export type UnsubscribeOptions = {
295
330
  /** The ref to unsubscribe from. */
@@ -639,6 +674,26 @@ export class SubscriptionService {
639
674
  return identity === undefined ? undefined : this.indexCache.readCached(identity);
640
675
  }
641
676
 
677
+ /**
678
+ * Every trusted repository as a reference searches it, read from the cached
679
+ * indexes alone: what it publishes, where each recipe lives, and its
680
+ * canonical identity, so a reference written as a location finds it whatever
681
+ * this project calls it. A repository whose index has never been fetched can
682
+ * still be named, just not searched inside.
683
+ */
684
+ cachedReferenceRepos(): ReferenceRepo[] {
685
+ const repos = this.resolverRepos();
686
+ return Object.keys(repos).map((name) => {
687
+ const { url, identity } = repos[name]!;
688
+ const index = this.cachedIndex(name);
689
+ const base =
690
+ index === undefined
691
+ ? { name, url, namespaces: [], recipes: [] }
692
+ : referenceReposFromIndexes([name], new Map([[name, index]]), { [name]: url })[0]!;
693
+ return { ...base, identity };
694
+ });
695
+ }
696
+
642
697
  // --- Repository identity ----------------------------------------------------------------------
643
698
 
644
699
  /**
@@ -796,27 +851,30 @@ export class SubscriptionService {
796
851
  * @param options - The ref, the prerelease and always-pull flags, and the trust flag.
797
852
  */
798
853
  async subscribe(options: SubscribeOptions): Promise<SubscribeOutcome> {
799
- const written = parseRef(options.ref);
800
854
  const dryRun = options.dryRun === true;
801
855
 
802
- // A one-word ref is a guess at a name, and the guess is settled here, from
803
- // the cached indexes alone. Everything after this point works with a fully
804
- // qualified ref, so what is confirmed is exactly what is installed.
805
- const parsed = await this.resolveBareRef(written, options);
856
+ // What the ref names is settled here, from the cached indexes alone: a
857
+ // one-word ref is a guess at a name, and a location is matched against the
858
+ // repository at that location (trusted first, when it is not yet). Every
859
+ // step after this works with a fully qualified ref, so what is confirmed
860
+ // is exactly what is installed.
861
+ const { parsed, trusted: trustedForRef } = await this.resolveWrittenRef(options.ref, options);
806
862
  const key = refKey(parsed);
807
863
 
808
864
  // The last gate before anything is fetched or written: what this will do to
809
865
  // the project, in plain sentences, and a question.
810
866
  await this.confirmSubscription(parsed, options);
811
867
 
812
- const { resolved, trusted, cycles } = await this.resolveClosure(parsed, options);
868
+ const closure = await this.resolveClosure(parsed, options);
869
+ const { resolved, cycles } = closure;
870
+ const trusted = [...trustedForRef, ...closure.trusted];
813
871
 
814
872
  const before = this.lock.read();
815
873
  const after = this.lock.applyResolution(before, resolved, this.lockRepoInputs());
816
874
  const diff = this.lock.diff(before, after);
817
875
 
818
876
  const resolvedFrom =
819
- formatRef(written) === formatRef(parsed) ? {} : { resolvedFrom: formatRef(written) };
877
+ options.ref.trim() === formatRef(parsed) ? {} : { resolvedFrom: options.ref.trim() };
820
878
 
821
879
  if (dryRun) {
822
880
  // The questions are planned even here, so `--dry-run` is the command an
@@ -881,6 +939,161 @@ export class SubscriptionService {
881
939
 
882
940
  // --- Working out what a one-word ref meant -----------------------------------------------------
883
941
 
942
+ /**
943
+ * Settles what a ref typed on the command line names, in any form it was
944
+ * written: a short ref goes through `resolveBareRef`; a location (a URL, an
945
+ * SSH remote, a provider-scheme locator, a browser URL) is matched against
946
+ * the trusted repository at that location and settled through its index.
947
+ * When no reading's repository is trusted yet, the trust ceremony runs for
948
+ * it first, exactly as it runs for a dependency's repository, and nothing is
949
+ * fetched from it before the answer.
950
+ *
951
+ * @param written - The ref exactly as the user wrote it.
952
+ * @param options - The trust and accept-first flags.
953
+ * @returns The fully qualified ref, and any repository trusted to settle it.
954
+ */
955
+ private async resolveWrittenRef(
956
+ written: string,
957
+ options: SubscribeOptions
958
+ ): Promise<{ parsed: ParsedRef; trusted: string[] }> {
959
+ const readings = parseRef(written, RefSource.CommandLine, this.providers);
960
+ const first = readings[0]!;
961
+ if (first.location === undefined && isNamedReading(first)) {
962
+ return { parsed: await this.resolveBareRef(first, options), trusted: [] };
963
+ }
964
+
965
+ const trusted = await this.trustLocation(written, readings, options);
966
+ const chosen = await this.pickLocated(written, options.acceptFirst);
967
+ const range = "range" in first ? first.range : undefined;
968
+ return {
969
+ parsed: referenceToRef(chosen, {
970
+ namespace: chosen.namespace!,
971
+ ...(range === undefined ? {} : { range }),
972
+ }),
973
+ trusted,
974
+ };
975
+ }
976
+
977
+ /**
978
+ * The one namespace or recipe a location names among the trusted
979
+ * repositories, settled through their cached indexes and chosen between the
980
+ * way every reference is.
981
+ *
982
+ * @param written - The location as written.
983
+ * @param acceptFirst - Whether `--accept-first` was passed.
984
+ */
985
+ private async pickLocated(written: string, acceptFirst?: boolean): Promise<ReferenceMatch> {
986
+ const repoOrder = this.repoSearchOrder();
987
+ const indexes = await this.loadIndexes(repoOrder);
988
+ this.indexesSnapshot = indexes;
989
+
990
+ const matches = findReference(
991
+ written,
992
+ [SousScope.Namespace, SousScope.Recipe],
993
+ { repos: this.referenceRepos(repoOrder, indexes) }
994
+ );
995
+
996
+ return pickReference(matches, {
997
+ search: written.trim(),
998
+ interactive: this.interactive,
999
+ ...(acceptFirst === undefined ? {} : { acceptFirst }),
1000
+ details: [
1001
+ ` The repository at that location is trusted, but its index publishes no namespace ` +
1002
+ `or recipe there.`,
1003
+ ` Run 'sous repo list --latest' to read what it publishes now.`,
1004
+ ],
1005
+ write: (message: string) => this.write(message),
1006
+ choose: (message, offered) => this.choose(message, offered),
1007
+ });
1008
+ }
1009
+
1010
+ /**
1011
+ * The trusted repositories as a reference searches them, each carrying its
1012
+ * canonical identity so a location finds it whatever this project calls it.
1013
+ *
1014
+ * @param repoOrder - The repositories, in search order.
1015
+ * @param indexes - Their indexes.
1016
+ */
1017
+ private referenceRepos(repoOrder: string[], indexes: Map<string, IndexFile>) {
1018
+ const repos = this.resolverRepos();
1019
+ const urls: Record<string, string | undefined> = {};
1020
+ for (const name of repoOrder) urls[name] = repos[name]?.url;
1021
+ return referenceReposFromIndexes(repoOrder, indexes, urls).map((repo) => ({
1022
+ ...repo,
1023
+ ...(repos[repo.name] === undefined ? {} : { identity: repos[repo.name]!.identity }),
1024
+ }));
1025
+ }
1026
+
1027
+ /**
1028
+ * Makes sure the repository a located ref names is trusted: nothing happens
1029
+ * when any reading's repository already is. Otherwise the one repository the
1030
+ * location names goes through the trust ceremony; a location that reads as
1031
+ * several repositories (a GitLab URL with nested groups) is first a question
1032
+ * of which one, since nothing may be fetched from a repository to find out
1033
+ * before it is trusted.
1034
+ *
1035
+ * @param written - The location as written.
1036
+ * @param readings - Every reading of it.
1037
+ * @param options - The trust and accept-first flags.
1038
+ * @returns The short names of the repositories trusted here.
1039
+ */
1040
+ private async trustLocation(
1041
+ written: string,
1042
+ readings: RefReading[],
1043
+ options: SubscribeOptions
1044
+ ): Promise<string[]> {
1045
+ const repos = this.resolverRepos();
1046
+ const known = new Set(Object.values(repos).map((repo) => repo.identity));
1047
+ const located = readings.filter((reading) => reading.location !== undefined);
1048
+ if (located.some((reading) => known.has(reading.location!.identity))) return [];
1049
+
1050
+ let chosen = located[0]!;
1051
+ const identities = new Set(located.map((reading) => reading.location!.identity));
1052
+ if (identities.size > 1) {
1053
+ const offered: ReferenceMatch[] = located.map((reading) => ({
1054
+ scope:
1055
+ isNamedReading(reading) && reading.recipe !== undefined
1056
+ ? SousScope.Recipe
1057
+ : SousScope.Namespace,
1058
+ key: formatRef(reading, this.providers),
1059
+ label: formatRef(reading, this.providers),
1060
+ repo: reading.location!.url,
1061
+ ...(isNamedReading(reading) ? { namespace: reading.namespace } : {}),
1062
+ ...(isNamedReading(reading) && reading.recipe !== undefined
1063
+ ? { recipe: reading.recipe }
1064
+ : {}),
1065
+ qualification: Qualification.Full,
1066
+ }));
1067
+ const picked = await pickReference(offered, {
1068
+ search: written.trim(),
1069
+ interactive: this.interactive,
1070
+ ...(options.acceptFirst === undefined ? {} : { acceptFirst: options.acceptFirst }),
1071
+ prompt: `'${written.trim()}' can be read as more than one repository. Which one?`,
1072
+ write: (message: string) => this.write(message),
1073
+ choose: (message, list) => this.choose(message, list),
1074
+ });
1075
+ chosen = located[offered.indexOf(picked)]!;
1076
+ }
1077
+
1078
+ const location = chosen.location!;
1079
+ const outcome = await this.trust.confirmTrust(
1080
+ [
1081
+ {
1082
+ name: proposeRepoName(repos, location.identity),
1083
+ url: location.url,
1084
+ identity: location.identity,
1085
+ provider: location.provider,
1086
+ requiredBy: [{ ref: written.trim(), requestedBy: PROJECT_REQUESTER }],
1087
+ },
1088
+ ],
1089
+ {
1090
+ interactive: this.interactive,
1091
+ ...(options.trust === undefined ? {} : { trustFlag: options.trust }),
1092
+ }
1093
+ );
1094
+ return outcome.added;
1095
+ }
1096
+
884
1097
  /**
885
1098
  * Settles what a ref names, reading nothing but the cached indexes.
886
1099
  *
@@ -898,33 +1111,39 @@ export class SubscriptionService {
898
1111
  written: ParsedRef,
899
1112
  options: SubscribeOptions
900
1113
  ): Promise<ParsedRef> {
901
- if (written.recipe !== undefined) return written;
902
-
903
- const repoOrder = this.repoSearchOrder(written.repo);
1114
+ // A ref that names a recipe, spelled as it is published, says what it
1115
+ // names already. One word, or a spelling in another case, is searched.
1116
+ const search =
1117
+ written.repo === undefined ? refKey(written) : `${written.repo}:${refKey(written)}`;
1118
+ if (written.recipe !== undefined && refKey(written) === refKey(written).toLowerCase()) {
1119
+ return written;
1120
+ }
1121
+
1122
+ const qualifier =
1123
+ written.repo === undefined
1124
+ ? undefined
1125
+ : (matchNames(written.repo, Object.keys(this.currentRepos()))[0] ?? written.repo);
1126
+ const repoOrder = this.repoSearchOrder(qualifier);
904
1127
  const indexes = await this.loadIndexes(repoOrder);
905
1128
  if (written.repo === undefined) this.indexesSnapshot = indexes;
906
1129
 
907
1130
  const context = { repos: referenceReposFromIndexes(repoOrder, indexes) };
908
- const matches = findReference(
909
- written.namespace,
910
- [SousScope.Namespace, SousScope.Recipe],
911
- context
912
- );
1131
+ const matches = findReference(search, [SousScope.Namespace, SousScope.Recipe], context);
913
1132
 
914
1133
  if (matches.length === 0) {
915
1134
  throw new ConfigError(
916
1135
  [
917
- `Nothing called '${written.namespace}' was found: no namespace has that name, ` +
1136
+ `Nothing called '${refKey(written)}' was found: no namespace has that name, ` +
918
1137
  `and no recipe does either.`,
919
- ...describeIndexSearch({ name: written.namespace, repoOrder, indexes }),
920
- ` Run 'sous repo search ${written.namespace}' to look for something like it, or ` +
1138
+ ...describeIndexSearch({ name: refKey(written), repoOrder, indexes }),
1139
+ ` Run 'sous repo search ${refKey(written)}' to look for something like it, or ` +
921
1140
  `'sous repo add <url>' to add the repository that publishes it.`,
922
1141
  ].join("\n")
923
1142
  );
924
1143
  }
925
1144
 
926
1145
  const chosen = await pickReference(matches, {
927
- search: written.namespace,
1146
+ search,
928
1147
  interactive: this.interactive,
929
1148
  ...(options.acceptFirst === undefined ? {} : { acceptFirst: options.acceptFirst }),
930
1149
  write: (message: string) => this.write(message),
@@ -1272,6 +1491,26 @@ export class SubscriptionService {
1272
1491
  }
1273
1492
  }
1274
1493
 
1494
+ /**
1495
+ * The subscription key a ref typed on the command line names. A short ref
1496
+ * names its own key, matched against the keys in force exactly first and then
1497
+ * ignoring case; a location is settled through the trusted repository at that
1498
+ * location, the way `subscribe` settles one.
1499
+ *
1500
+ * @param written - The ref as typed.
1501
+ * @param keys - The subscription keys in force.
1502
+ */
1503
+ private async subscriptionKeyFor(written: string, keys: string[]): Promise<string> {
1504
+ const readings = parseRef(written, RefSource.CommandLine, this.providers);
1505
+ const first = readings[0]!;
1506
+ if (first.location === undefined && isNamedReading(first)) {
1507
+ const key = refKey(first);
1508
+ return matchNames(key, new Set(keys))[0] ?? key;
1509
+ }
1510
+ const chosen = await this.pickLocated(written);
1511
+ return refKey(referenceToRef(chosen, { namespace: chosen.namespace! }));
1512
+ }
1513
+
1275
1514
  /**
1276
1515
  * Removes one subscription and everything that was only there because of it.
1277
1516
  * Removal is refcounted: a recipe another subscription (or another recipe)
@@ -1280,12 +1519,14 @@ export class SubscriptionService {
1280
1519
  * @param options - The ref to unsubscribe from.
1281
1520
  */
1282
1521
  async unsubscribe(options: UnsubscribeOptions): Promise<UnsubscribeOutcome> {
1283
- const parsed = parseRef(options.ref);
1284
- const key = refKey(parsed);
1285
1522
  const dryRun = options.dryRun === true;
1286
1523
 
1287
1524
  const managed = this.readSubscriptionEntries();
1288
1525
  const configured = enabledSubscriptions(this.settings);
1526
+ const key = await this.subscriptionKeyFor(options.ref, [
1527
+ ...Object.keys(managed),
1528
+ ...Object.keys(configured),
1529
+ ]);
1289
1530
  const before = this.lock.read();
1290
1531
  const held = this.keysHeldBySubscription(before, key);
1291
1532
 
@@ -1521,7 +1762,7 @@ export class SubscriptionService {
1521
1762
  for (const key of Object.keys(subscriptions).sort()) {
1522
1763
  let qualifier: string | undefined;
1523
1764
  try {
1524
- qualifier = parseRef(key).repo;
1765
+ qualifier = parseShortRef(key, RefSource.Config).repo;
1525
1766
  } catch {
1526
1767
  // A ref that does not parse cannot name this repository, and reporting
1527
1768
  // it here would bury the removal under an unrelated complaint.
@@ -1974,7 +2215,7 @@ export class SubscriptionService {
1974
2215
  const entry = subscriptions[key]!;
1975
2216
  let request: RefRequest;
1976
2217
  try {
1977
- const parsed = parseRef(key);
2218
+ const parsed = parseShortRef(key, RefSource.Config);
1978
2219
  request = {
1979
2220
  ref: { ...parsed, ...(entry.range === undefined ? {} : { range: entry.range }) },
1980
2221
  requestedBy: PROJECT_REQUESTER,
@@ -2082,7 +2323,7 @@ export class SubscriptionService {
2082
2323
 
2083
2324
  let parsed: ParsedRef;
2084
2325
  try {
2085
- parsed = parseRef(key);
2326
+ parsed = parseShortRef(key, RefSource.Config);
2086
2327
  } catch {
2087
2328
  return false;
2088
2329
  }
@@ -2127,7 +2368,7 @@ export class SubscriptionService {
2127
2368
  ): Lockfile {
2128
2369
  const coverOf = (key: string): string | undefined => {
2129
2370
  try {
2130
- return refKey(parseRef(key));
2371
+ return refKey(parseShortRef(key, RefSource.Config));
2131
2372
  } catch {
2132
2373
  return undefined;
2133
2374
  }
@@ -2217,7 +2458,7 @@ export class SubscriptionService {
2217
2458
  if (scope.kind === "all") return true;
2218
2459
  let parsed: ParsedRef;
2219
2460
  try {
2220
- parsed = parseRef(key);
2461
+ parsed = parseShortRef(key, RefSource.Config);
2221
2462
  } catch {
2222
2463
  return false;
2223
2464
  }
@@ -2311,7 +2552,7 @@ export class SubscriptionService {
2311
2552
  if (entry.repo !== repoName) continue;
2312
2553
  const range = this.effectiveRangeFor(key, entry, subscriptions);
2313
2554
  if (range === undefined) continue;
2314
- const subscription = subscriptions[key] ?? subscriptions[key.split("/")[0]!];
2555
+ const subscription = subscriptions[key] ?? subscriptions[namespaceOfKey(key)];
2315
2556
 
2316
2557
  const found = findNewerInRange({
2317
2558
  index,
@@ -2403,7 +2644,7 @@ export class SubscriptionService {
2403
2644
 
2404
2645
  let parsed: ParsedRef;
2405
2646
  try {
2406
- parsed = parseRef(key);
2647
+ parsed = parseShortRef(key, RefSource.Config);
2407
2648
  } catch (error) {
2408
2649
  report.failed.push({ key, reason: describeError(error) });
2409
2650
  continue;
@@ -2670,7 +2911,7 @@ export class SubscriptionService {
2670
2911
 
2671
2912
  for (const [key, entry] of Object.entries(recipes)) {
2672
2913
  if (entry.repo !== repoName) continue;
2673
- const subscription = subscriptions[key] ?? subscriptions[key.split("/")[0]!];
2914
+ const subscription = subscriptions[key] ?? subscriptions[namespaceOfKey(key)];
2674
2915
 
2675
2916
  // Always-pull re-resolves WITHIN what was declared; it never widens it.
2676
2917
  // A recipe held only through another recipe's `depends` has no
@@ -3062,11 +3303,11 @@ export class SubscriptionService {
3062
3303
  const provider = requireProvider(request.url, request.providerId, this.providers);
3063
3304
  const canonical = provider.canonicalize(request.url);
3064
3305
 
3065
- const namespace = request.key.slice(0, request.key.indexOf("/"));
3306
+ const { namespace, name } = splitRecipeKey(request.key);
3066
3307
  const key: StoreKey = {
3067
3308
  identity: request.identity,
3068
3309
  namespace,
3069
- name: request.key.slice(namespace.length + 1),
3310
+ name,
3070
3311
  version: request.version,
3071
3312
  };
3072
3313
 
@@ -3231,7 +3472,7 @@ export class SubscriptionService {
3231
3472
  entry: LockedRecipe,
3232
3473
  subscriptions: Record<string, SubscriptionEntry>
3233
3474
  ): string | undefined {
3234
- const namespace = key.split("/")[0]!;
3475
+ const namespace = namespaceOfKey(key);
3235
3476
  return effectiveRangeForHolders(key, entry.requestedBy, {
3236
3477
  subscriptionRange: (held) => {
3237
3478
  // A subscription the config no longer declares is not a hold sous can
@@ -3270,15 +3511,20 @@ export class SubscriptionService {
3270
3511
  if (manifest === undefined) return undefined;
3271
3512
 
3272
3513
  for (const dependency of manifest.depends ?? []) {
3273
- let parsed: ParsedRef;
3514
+ // Every reading counts: a dependency that reads more than one way still
3515
+ // declares its range for whichever reading the lockfile settled on.
3516
+ let readings: RefReading[];
3274
3517
  try {
3275
- parsed = parseRef(dependency);
3518
+ readings = parseRef(dependency, RefSource.Manifest, this.providers);
3276
3519
  } catch {
3277
3520
  continue;
3278
3521
  }
3279
- const dependencyKey = refKey(parsed);
3280
- if (dependencyKey !== key && dependencyKey !== namespace) continue;
3281
- return parsed.range ?? "*";
3522
+ for (const reading of readings) {
3523
+ if (!isNamedReading(reading)) continue;
3524
+ const dependencyKey = refKey(reading);
3525
+ if (dependencyKey !== key && dependencyKey !== namespace) continue;
3526
+ return reading.range ?? "*";
3527
+ }
3282
3528
  }
3283
3529
 
3284
3530
  return undefined;
@@ -3305,9 +3551,10 @@ export class SubscriptionService {
3305
3551
  * Every variable definition the resolved closure publishes, attributed to the
3306
3552
  * recipe that declared it and to the chain that pulled it in.
3307
3553
  *
3308
- * A recipe whose files are not on this machine contributes nothing rather
3309
- * than failing, which is what lets a dry run describe as much of the closure
3310
- * 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.
3311
3558
  *
3312
3559
  * @param resolved - The recipe versions the resolution settled on.
3313
3560
  */
@@ -3357,12 +3604,12 @@ export class SubscriptionService {
3357
3604
  });
3358
3605
 
3359
3606
  for (const recipe of ordered) {
3360
- const manifest = readRecipeManifestIn(this.recipeDirectory(recipe));
3361
- if (manifest === undefined) continue;
3607
+ const variables = this.publishedVariables(recipe);
3608
+ if (variables === undefined) continue;
3362
3609
 
3363
3610
  const publisher = describe(recipe);
3364
3611
  const requiredBy = chainFor(recipe).map(describe);
3365
- for (const definition of manifest.variables ?? []) {
3612
+ for (const definition of variables) {
3366
3613
  defined.push({ definition, recipe: publisher, requiredBy });
3367
3614
  }
3368
3615
  }
@@ -3380,11 +3627,70 @@ export class SubscriptionService {
3380
3627
  */
3381
3628
  private unreadableRecipes(resolved: ResolvedRecipe[]): string[] {
3382
3629
  return resolved
3383
- .filter((recipe) => readRecipeManifestIn(this.recipeDirectory(recipe)) === undefined)
3630
+ .filter((recipe) => this.publishedVariables(recipe) === undefined)
3384
3631
  .map((recipe) => recipe.key)
3385
3632
  .sort();
3386
3633
  }
3387
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
+
3388
3694
  /** The environment layers and mapping records this project resolves against. */
3389
3695
  private ladderContext(): LadderContext {
3390
3696
  return loadLadderContext({
@@ -31,7 +31,7 @@ export type UpdatePlanFacts = {
31
31
  missingRepos: MissingRepo[];
32
32
  /** The questions the new versions ask that nothing answers yet. */
33
33
  questions: PlannedVariable[];
34
- /** Recipes whose files are not on this machine, so their questions are unknown. */
34
+ /** Recipes whose files are not on this machine and whose index does not record their questions. */
35
35
  unreadable: string[];
36
36
  /** Repositories whose index could not be fetched, so their pins stay where they are. */
37
37
  unreachable: Array<{ repo: string; reason: string }>;
@@ -153,7 +153,8 @@ export function formatUpdatePlan(
153
153
  if (facts.unreadable.length > 0) {
154
154
  notes.push(
155
155
  `A dry run downloads no recipe, so the dependencies of the versions not on this ` +
156
- `machine yet are not shown: ${facts.unreadable.join(", ")}.`
156
+ `machine yet, and not described by their index, are not shown: ` +
157
+ `${facts.unreadable.join(", ")}.`
157
158
  );
158
159
  }
159
160
  for (const entry of facts.unreachable) {