@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
@@ -23,6 +23,40 @@ export enum SousScope {
23
23
  EnvVarName = "envVarName",
24
24
  }
25
25
 
26
+ /**
27
+ * Where a ref was written. `parseRef` reads every form wherever a ref comes
28
+ * from, and the place decides which of those forms it allows: a refused form
29
+ * is an error saying what to write there instead.
30
+ */
31
+ export enum RefSource {
32
+ /**
33
+ * A ref typed on the command line. Every form is allowed: a bare name, a
34
+ * namespace and recipe, a `repo:` qualifier, a version range, a
35
+ * provider-scheme locator, an HTTPS, SSH or scheme-less URL, and a browser
36
+ * URL copied from a host's file view. Names may be written in any case.
37
+ */
38
+ CommandLine = "commandLine",
39
+ /**
40
+ * A subscription key in a config layer. Only the stored form is allowed:
41
+ * `namespace` or `namespace/recipe`, lowercase. The repository a
42
+ * subscription resolves into is recorded in the lockfile, and its range in
43
+ * the entry's own `range` field.
44
+ */
45
+ Config = "config",
46
+ /**
47
+ * An entry of a recipe manifest's `depends` or `subscribes` list. A bare ref
48
+ * names a recipe in the same repository; every locator and URL form names
49
+ * one in another repository. A `repo:` qualifier is refused, because it is
50
+ * one project's private name for a repository, and so is a local path.
51
+ */
52
+ Manifest = "manifest",
53
+ /**
54
+ * A key sous wrote itself: the lockfile, the index and the store. Only the
55
+ * canonical `namespace` or `namespace/recipe` is allowed.
56
+ */
57
+ Lockfile = "lockfile",
58
+ }
59
+
26
60
  /**
27
61
  * Every scope, in the order matches of equal specificity are listed in. It is
28
62
  * the order the one-word ref search has always used (a whole namespace before
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Settling what a ref means, once the index that can say is at hand.
3
+ *
4
+ * `parseRef` returns every reading of a written ref. Some readings are settled
5
+ * the moment they are read (`workflow/alpha`), and some need the index of the
6
+ * repository they point at: a browser URL names a folder, and only the `path`
7
+ * each recipe's index entry records says which recipe that folder is; a GitLab
8
+ * URL with nested groups can be read as several repositories, and only their
9
+ * indexes say which one publishes what was named.
10
+ *
11
+ * Names are matched the same way everywhere: the exact spelling first, then
12
+ * ignoring case. A name that still matches several things is a question for
13
+ * `pickReference`.
14
+ */
15
+
16
+ import {
17
+ isBrowsedReading,
18
+ isNamedReading,
19
+ namespaceOfKey,
20
+ parseRef,
21
+ refKey,
22
+ splitRecipeKey,
23
+ type ParsedRef,
24
+ type RefReading,
25
+ } from "./parse.js";
26
+ import { RefSource } from "./scopes.js";
27
+
28
+ /**
29
+ * The names that match a written one: those spelled exactly as written, or,
30
+ * when there is none, those that differ only in case.
31
+ *
32
+ * matchNames("Workflow", ["workflow", "tooling"]); // -> ["workflow"]
33
+ * matchNames("workflow", ["workflow", "Workflow"]); // -> ["workflow"]
34
+ *
35
+ * @param written - The name as written.
36
+ * @param names - The names it could mean.
37
+ */
38
+ export function matchNames(written: string, names: Iterable<string>): string[] {
39
+ const all = [...names];
40
+ const exact = all.filter((name) => name === written);
41
+ if (exact.length > 0) return exact;
42
+ const folded = written.toLowerCase();
43
+ return all.filter((name) => name.toLowerCase() === folded);
44
+ }
45
+
46
+ /** What settling needs to know about one repository's index. */
47
+ export type SettleIndex = {
48
+ /** Every namespace the index publishes. */
49
+ namespaces: Iterable<string>;
50
+ /** Every recipe it publishes, keyed `namespace/recipe`, with the folder it lives in. */
51
+ recipes: Record<string, { path: string }>;
52
+ };
53
+
54
+ /**
55
+ * What a reading names in one repository's index, spelled the way the index
56
+ * spells it. A named reading that the index does not publish settles to
57
+ * nothing; a browsed path settles through the recipe paths.
58
+ *
59
+ * settleInIndex({ namespace: "Workflow", recipe: "alpha" }, index);
60
+ * // -> [{ namespace: "workflow", recipe: "alpha" }]
61
+ *
62
+ * @param reading - One reading of a written ref.
63
+ * @param index - The index of the repository the reading points at.
64
+ */
65
+ export function settleInIndex(reading: RefReading, index: SettleIndex): ParsedRef[] {
66
+ const carried = {
67
+ ...(reading.location === undefined ? {} : { location: reading.location }),
68
+ ...("range" in reading && reading.range !== undefined ? { range: reading.range } : {}),
69
+ ...("repo" in reading && reading.repo !== undefined ? { repo: reading.repo } : {}),
70
+ };
71
+
72
+ if (isBrowsedReading(reading)) {
73
+ return settleBrowsed(reading.browsed, index).map((settled) => ({ ...settled, ...carried }));
74
+ }
75
+ if (!isNamedReading(reading)) return [];
76
+
77
+ if (reading.recipe === undefined) {
78
+ return matchNames(reading.namespace, index.namespaces).map((namespace) => ({
79
+ namespace,
80
+ ...carried,
81
+ }));
82
+ }
83
+
84
+ return matchNames(refKey(reading), Object.keys(index.recipes)).map((key) => {
85
+ const { namespace, name } = splitRecipeKey(key);
86
+ return { namespace, recipe: name, ...carried };
87
+ });
88
+ }
89
+
90
+ /**
91
+ * What a browser path names, through the folder each recipe's index entry
92
+ * records. The path still carries the branch in front (which may itself hold
93
+ * slashes), so every split is tried: the folder of a recipe, or anything inside
94
+ * it, names that recipe; a folder holding recipes of exactly one namespace
95
+ * names that namespace.
96
+ *
97
+ * settleBrowsed("main/recipes/workflow/alpha/SKILL.md", index);
98
+ * // -> [{ namespace: "workflow", recipe: "alpha" }]
99
+ *
100
+ * @param browsed - The path after `tree/` or `blob/`, branch included.
101
+ * @param index - The repository's index.
102
+ */
103
+ export function settleBrowsed(browsed: string, index: SettleIndex): ParsedRef[] {
104
+ const segments = browsed.split("/").filter((segment) => segment.length > 0);
105
+ const recipes = Object.entries(index.recipes).map(([key, entry]) => ({
106
+ key,
107
+ path: entry.path.replace(/^\.?\/+|\/+$/g, ""),
108
+ }));
109
+
110
+ const found = new Map<string, ParsedRef>();
111
+
112
+ // A recipe's folder, or a file inside it. The deepest recipe wins, so a
113
+ // recipe nested inside another's folder is named by its own path.
114
+ for (let start = 1; start < segments.length; start++) {
115
+ const folder = segments.slice(start).join("/");
116
+ const holding = recipes
117
+ .filter((recipe) => folder === recipe.path || folder.startsWith(`${recipe.path}/`))
118
+ .sort((left, right) => right.path.length - left.path.length);
119
+ const deepest = holding[0];
120
+ if (deepest !== undefined) {
121
+ const { namespace, name } = splitRecipeKey(deepest.key);
122
+ found.set(deepest.key, { namespace, recipe: name });
123
+ }
124
+ }
125
+ if (found.size > 0) return [...found.values()];
126
+
127
+ // A folder above recipes: a namespace, when everything under it is one.
128
+ for (let start = 1; start < segments.length; start++) {
129
+ const folder = segments.slice(start).join("/");
130
+ const under = recipes.filter((recipe) => recipe.path.startsWith(`${folder}/`));
131
+ const namespaces = new Set(under.map((recipe) => namespaceOfKey(recipe.key)));
132
+ if (namespaces.size === 1) {
133
+ const namespace = [...namespaces][0]!;
134
+ found.set(namespace, { namespace });
135
+ }
136
+ }
137
+ return [...found.values()];
138
+ }
139
+
140
+ /** What a repository's index recorded about one dependency, as far as settling needs. */
141
+ export type RecordedDependencies = Record<string, { repo?: string } | undefined>;
142
+
143
+ /**
144
+ * The readings of a manifest dependency that the declaring recipe's index
145
+ * confirms. A release settles a dependency that reads more than one way (a
146
+ * GitLab URL with nested groups, a browser URL) and records the repository it
147
+ * settled on beside each key it resolved; a consumer reads that record and never
148
+ * probes. A dependency that reads one way needs no record and comes back as it
149
+ * is.
150
+ *
151
+ * A named reading is confirmed by a record under its own key (or, for a whole
152
+ * namespace, under a key inside it) carrying its repository; a browsed reading
153
+ * by any record carrying its repository.
154
+ *
155
+ * @param readings - Every reading of the dependency, from `parseRef`.
156
+ * @param recorded - What the declaring version's index entry records, keyed `namespace/recipe`.
157
+ * @returns The confirmed readings: one is the answer; none or several leave it unsettled.
158
+ */
159
+ export function confirmedReadings(
160
+ readings: RefReading[],
161
+ recorded: RecordedDependencies | undefined
162
+ ): RefReading[] {
163
+ if (readings.length === 1) return readings;
164
+ const records = Object.entries(recorded ?? {});
165
+
166
+ return readings.filter((reading) => {
167
+ const identity = reading.location?.identity;
168
+ if (identity === undefined) return false;
169
+ return records.some(([key, record]) => {
170
+ if (record?.repo !== identity) return false;
171
+ if (isBrowsedReading(reading)) return true;
172
+ if (!isNamedReading(reading)) return false;
173
+ return reading.recipe === undefined
174
+ ? key.startsWith(`${reading.namespace}/`)
175
+ : key === refKey(reading);
176
+ });
177
+ });
178
+ }
179
+
180
+ /** What `settleDependency` may consult, beyond the dependency itself. */
181
+ export type SettleDependencyOptions = {
182
+ /** What the declaring version's index entry records about its dependencies. */
183
+ recorded?: RecordedDependencies;
184
+ /**
185
+ * Whether a reading is already known to be right from what is on this
186
+ * machine: its repository is trusted and its cached index publishes what it
187
+ * names. Used only when the index recorded nothing, and it never fetches.
188
+ */
189
+ known?: (reading: RefReading) => boolean;
190
+ };
191
+
192
+ /**
193
+ * The one reading a manifest dependency settles on, or undefined when it reads
194
+ * more than one way and nothing at hand says which: the declaring recipe's
195
+ * index recorded no answer, and what this machine already knows does not pick
196
+ * exactly one.
197
+ *
198
+ * settleDependency("workflow/alpha");
199
+ * // -> { namespace: "workflow", recipe: "alpha" }
200
+ *
201
+ * @param written - The dependency as the manifest wrote it.
202
+ * @param options - The index's record, and what is known locally.
203
+ */
204
+ export function settleDependency(
205
+ written: string,
206
+ options: SettleDependencyOptions = {}
207
+ ): RefReading | undefined {
208
+ const readings = parseRef(written, RefSource.Manifest);
209
+ if (readings.length === 1) return readings[0];
210
+
211
+ const confirmed = confirmedReadings(readings, options.recorded);
212
+ if (confirmed.length === 1) return confirmed[0];
213
+
214
+ if (options.known !== undefined) {
215
+ const known = readings.filter(options.known);
216
+ if (known.length === 1) return known[0];
217
+ }
218
+ return undefined;
219
+ }
@@ -8,11 +8,12 @@
8
8
  * This module answers all four.
9
9
  *
10
10
  * Everything here is a pure function over data the caller hands it: the cached
11
- * indexes, the lockfile, and the subscription keys the project declares. A
12
- * recipe's own manifest (its variables, its declared dependencies and the
13
- * content it contributes) lives inside the recipe's files rather than in the
14
- * index, so the caller supplies a reader for it; a recipe whose files are not on
15
- * this machine is described from its index alone rather than being an error.
11
+ * indexes, the lockfile, and the subscription keys the project declares. The
12
+ * content a recipe contributes is known only from its own manifest, inside the
13
+ * recipe's files, so the caller supplies a reader for it; a recipe whose files
14
+ * are not on this machine is described from its index alone rather than being
15
+ * an error, and an index recorded by a current release says how each of its
16
+ * dependencies was declared.
16
17
  *
17
18
  * Refs resolve the way `sous subscribe` resolves them: `namespace`,
18
19
  * `namespace/recipe`, and either of those qualified with `repo:`. A one-word ref
@@ -24,13 +25,17 @@ import semver from "semver";
24
25
  import { ConfigError } from "../errors.js";
25
26
  import type { IndexDependency, IndexFile } from "./formats/index-file.js";
26
27
  import type { Lockfile } from "./formats/lockfile.js";
27
- import type {
28
- ContentKind,
29
- RecipeManifest,
30
- VariableDefinition,
31
- } from "./formats/recipe-manifest.js";
32
- import { dependencyRefKey, parseDependencyRef, parseRef, type ParsedRef } from "./ref.js";
33
- import { bareName } from "../vars/names.js";
28
+ import type { ContentKind, RecipeManifest } from "./formats/recipe-manifest.js";
29
+ import {
30
+ isNamedReading,
31
+ namespaceOfKey,
32
+ parseRef,
33
+ refKey,
34
+ splitRecipeKey,
35
+ type ParsedRef,
36
+ } from "../refs/parse.js";
37
+ import { settleDependency } from "../refs/settle.js";
38
+ import { declarationFor, type DependencyLists } from "./declarations.js";
34
39
  import {
35
40
  describeReference,
36
41
  findNamespace,
@@ -166,9 +171,13 @@ export type RecipeVersionListing = {
166
171
  export type RecipeDependencyListing = {
167
172
  /** The recipe key the dependency names, `namespace/recipe`. */
168
173
  key: string;
169
- /** The dependency exactly as the recipe's manifest wrote it, when it could be read. */
174
+ /**
175
+ * The manifest entry that brings it in, exactly as written (a recipe, a whole
176
+ * namespace, or a locator), from the index when it records one and from the
177
+ * manifest otherwise.
178
+ */
170
179
  declared?: string;
171
- /** Whether the manifest declared it a build dependency or a co-subscription. */
180
+ /** Whether that entry makes it a build dependency or a co-subscription. */
172
181
  kind?: "depends" | "subscribes";
173
182
  /** The exact version the release resolved it to, when the index records one. */
174
183
  resolvedVersion?: string;
@@ -178,22 +187,6 @@ export type RecipeDependencyListing = {
178
187
  repo?: string;
179
188
  };
180
189
 
181
- /** One variable a recipe asks about. */
182
- export type RecipeVariableListing = {
183
- /** The variable's name, as the manifest declares it. */
184
- name: string;
185
- /** The type of answer it takes. */
186
- type: string;
187
- /** The environment variable an answer is stored under. */
188
- env: string;
189
- /** True when an answer is required before the recipe is usable. */
190
- required: boolean;
191
- /** True when the answer is a secret, which sous never prints. */
192
- secret: boolean;
193
- /** The one-line question it asks. */
194
- prompt: string;
195
- };
196
-
197
190
  /** One content kind a recipe contributes, and where its files would land. */
198
191
  export type RecipeContentListing = {
199
192
  /** The content kind, as the manifest declares it. */
@@ -238,14 +231,18 @@ export type RecipeDetail = {
238
231
  versions: RecipeVersionListing[];
239
232
  /** What the described version depends on, by key. */
240
233
  dependencies: RecipeDependencyListing[];
241
- /** The variables the recipe declares, in manifest order. */
242
- variables: RecipeVariableListing[];
234
+ /**
235
+ * True when the index records the described version's dependencies, even as
236
+ * none at all, so an empty list means "depends on nothing" rather than "not
237
+ * recorded".
238
+ */
239
+ dependenciesRecorded: boolean;
243
240
  /** What the recipe contributes, and where each kind's files land. */
244
241
  contents: RecipeContentListing[];
245
242
  /**
246
243
  * True when the recipe's own manifest could be read. When it is false, the
247
- * variables, the declared dependencies and the contents are unknown rather
248
- * than empty, because the recipe's files are not on this machine.
244
+ * contents are unknown rather than empty, because the recipe's files are not
245
+ * on this machine.
249
246
  */
250
247
  manifestRead: boolean;
251
248
  };
@@ -360,7 +357,7 @@ export function describeRecipe(inputs: CatalogInputs, ref: string): RecipeDetail
360
357
  ...(describing === undefined ? {} : { describing }),
361
358
  versions: versionListings(entry.versions, listing.latest, listing.pinned),
362
359
  dependencies: dependencyListings(published?.dependencies, manifest),
363
- variables: (manifest?.variables ?? []).map(variableListing),
360
+ dependenciesRecorded: published?.dependencies !== undefined,
364
361
  contents: (manifest?.contents ?? []).map((content) => ({
365
362
  kind: content.kind,
366
363
  include: [...content.include],
@@ -390,7 +387,7 @@ export function narrowToInstalled(inputs: CatalogInputs): CatalogInputs {
390
387
  ([key]) => inputs.lock.recipes[key]?.repo === repo.name
391
388
  )
392
389
  );
393
- const held = new Set(Object.keys(recipes).map((key) => key.slice(0, key.indexOf("/"))));
390
+ const held = new Set(Object.keys(recipes).map(namespaceOfKey));
394
391
  const namespaces = Object.fromEntries(
395
392
  Object.entries(repo.index.namespaces).filter(([namespace]) => held.has(namespace))
396
393
  );
@@ -468,16 +465,16 @@ export type ResolvedRecipeRef = {
468
465
  * @param ref - The namespace, optionally qualified with `repo:`.
469
466
  */
470
467
  export function resolveNamespaceRef(inputs: CatalogInputs, ref: string): ResolvedNamespace {
471
- const parsed = parseRef(ref);
468
+ const { parsed, search } = readCommandLineRef(ref);
472
469
 
473
- if (parsed.recipe !== undefined) {
470
+ if (parsed?.recipe !== undefined) {
474
471
  throw new ConfigError(
475
472
  `'${ref}' names the recipe '${parsed.recipe}', not a namespace.\n` +
476
473
  ` The namespace it belongs to is '${parsed.namespace}'.`
477
474
  );
478
475
  }
479
476
 
480
- const matches = findNamespace(refSearchString(parsed), referenceContext(inputs));
477
+ const matches = findNamespace(search, referenceContext(inputs));
481
478
 
482
479
  if (matches.length === 1) {
483
480
  return { repo: repoNamed(inputs, matches[0]!.repo!), namespace: matches[0]!.namespace! };
@@ -485,8 +482,8 @@ export function resolveNamespaceRef(inputs: CatalogInputs, ref: string): Resolve
485
482
 
486
483
  if (matches.length > 1) throw ambiguousError(ref, "namespace", matches);
487
484
 
488
- const asRecipe = findRecipe(refSearchString(parsed), referenceContext(inputs));
489
- if (parsed.repo === undefined && asRecipe.length > 0) {
485
+ const asRecipe = findRecipe(search, referenceContext(inputs));
486
+ if (parsed !== undefined && parsed.repo === undefined && asRecipe.length > 0) {
490
487
  throw new ConfigError(
491
488
  `No repository this project trusts publishes a namespace called ` +
492
489
  `'${parsed.namespace}'.\n` +
@@ -495,7 +492,7 @@ export function resolveNamespaceRef(inputs: CatalogInputs, ref: string): Resolve
495
492
  );
496
493
  }
497
494
 
498
- throw unknownError(inputs, ref, "namespace", parsed.repo);
495
+ throw unknownError(inputs, ref, "namespace", parsed?.repo);
499
496
  }
500
497
 
501
498
  /**
@@ -507,8 +504,7 @@ export function resolveNamespaceRef(inputs: CatalogInputs, ref: string): Resolve
507
504
  * @param ref - The recipe, as `namespace/recipe`, a bare recipe name, or either qualified with `repo:`.
508
505
  */
509
506
  export function resolveRecipeRef(inputs: CatalogInputs, ref: string): ResolvedRecipeRef {
510
- const parsed = parseRef(ref);
511
- const search = refSearchString(parsed);
507
+ const { parsed, search } = readCommandLineRef(ref);
512
508
  const context = referenceContext(inputs);
513
509
 
514
510
  const matches = findRecipe(search, context);
@@ -529,13 +525,13 @@ export function resolveRecipeRef(inputs: CatalogInputs, ref: string): ResolvedRe
529
525
  if (asNamespace.length > 0) {
530
526
  throw new ConfigError(
531
527
  `No repository this project trusts publishes a recipe called ` +
532
- `'${parsed.namespace}'.\n` +
528
+ `'${parsed?.namespace ?? ref}'.\n` +
533
529
  ` It is the name of a namespace:\n` +
534
530
  asNamespace.map((candidate) => ` ${describeReference(candidate)}`).join("\n")
535
531
  );
536
532
  }
537
533
 
538
- throw unknownError(inputs, ref, "recipe", parsed.repo);
534
+ throw unknownError(inputs, ref, "recipe", parsed?.repo);
539
535
  }
540
536
 
541
537
  // --- The pieces ---------------------------------------------------------------------------------
@@ -543,7 +539,7 @@ export function resolveRecipeRef(inputs: CatalogInputs, ref: string): ResolvedRe
543
539
  /** Every recipe key one index publishes in one namespace, sorted. */
544
540
  function recipeKeysIn(index: IndexFile, namespace: string): string[] {
545
541
  return Object.keys(index.recipes)
546
- .filter((key) => key.slice(0, key.indexOf("/")) === namespace)
542
+ .filter((key) => namespaceOfKey(key) === namespace)
547
543
  .sort();
548
544
  }
549
545
 
@@ -583,8 +579,7 @@ function recipeListing(
583
579
  ): RecipeListing {
584
580
  const lock = inputs.lock;
585
581
  const entry = repo.index.recipes[key]!;
586
- const namespace = key.slice(0, key.indexOf("/"));
587
- const name = key.slice(namespace.length + 1);
582
+ const { namespace, name } = splitRecipeKey(key);
588
583
  const latest = latestVersion(Object.keys(entry.versions), entry.versions);
589
584
 
590
585
  // A locked entry pins one recipe from one repository. Two repositories can
@@ -674,27 +669,50 @@ function dependencyListings(
674
669
  manifest: RecipeManifest | undefined
675
670
  ): RecipeDependencyListing[] {
676
671
  const rows = new Map<string, RecipeDependencyListing>();
672
+ const lists: DependencyLists | undefined =
673
+ manifest === undefined
674
+ ? undefined
675
+ : { depends: manifest.depends ?? [], subscribes: manifest.subscribes ?? [] };
677
676
 
678
677
  for (const [key, entry] of Object.entries(resolved ?? {})) {
678
+ // The index says how the release that published the version saw it. An
679
+ // entry recorded before the index said so is answered from the manifest,
680
+ // by the same rule the release applies.
681
+ const declaration =
682
+ entry.declared !== undefined && entry.kind !== undefined
683
+ ? { declared: entry.declared, kind: entry.kind }
684
+ : lists === undefined
685
+ ? undefined
686
+ : declarationFor(lists, key, resolved);
679
687
  rows.set(key, {
680
688
  key,
681
689
  ...(entry.version === undefined ? {} : { resolvedVersion: entry.version }),
682
690
  ...(entry.range === undefined ? {} : { resolvedRange: entry.range }),
683
691
  ...(entry.repo === undefined ? {} : { repo: entry.repo }),
692
+ ...(declaration ?? {}),
684
693
  });
685
694
  }
686
695
 
696
+ // A manifest entry that covers no row the index recorded is still shown, so a
697
+ // reader sees everything the manifest declares.
698
+ const covered = new Set([...rows.values()].map((row) => row.declared));
687
699
  for (const [kind, declared] of [
688
- ["depends", manifest?.depends ?? []],
689
- ["subscribes", manifest?.subscribes ?? []],
700
+ ["depends", lists?.depends ?? []],
701
+ ["subscribes", lists?.subscribes ?? []],
690
702
  ] as Array<["depends" | "subscribes", string[]]>) {
691
703
  for (const written of declared) {
692
- let key: string;
704
+ if (covered.has(written.trim())) continue;
705
+ // A dependency that reads more than one way is listed under the key the
706
+ // index recorded for it. One sous cannot parse or settle is still worth
707
+ // showing; it is listed under what it was written as, so the reader sees
708
+ // the entry as it stands.
709
+ let key = written;
693
710
  try {
694
- key = dependencyRefKey(parseDependencyRef(written));
711
+ const reading = settleDependency(written, {
712
+ ...(resolved === undefined ? {} : { recorded: resolved }),
713
+ });
714
+ if (reading !== undefined && isNamedReading(reading)) key = refKey(reading);
695
715
  } catch {
696
- // A manifest sous cannot parse is still worth showing; it is listed
697
- // under what it was written as, so the reader sees the bad entry.
698
716
  key = written;
699
717
  }
700
718
  rows.set(key, { ...(rows.get(key) ?? { key }), declared: written, kind });
@@ -704,25 +722,6 @@ function dependencyListings(
704
722
  return [...rows.values()].sort((left, right) => compare(left.key, right.key));
705
723
  }
706
724
 
707
- /**
708
- * One variable row: what it is called, what kind of answer it takes, and the
709
- * environment variable an answer is stored under.
710
- *
711
- * @param definition - The variable definition from the recipe's manifest.
712
- */
713
- function variableListing(definition: VariableDefinition): RecipeVariableListing {
714
- return {
715
- name: definition.name,
716
- type: definition.type,
717
- // The manifest may bind an existing environment variable; otherwise the
718
- // answer is stored under the name sous derives from the variable's own.
719
- env: bareName(definition),
720
- required: definition.required,
721
- secret: definition.secret,
722
- prompt: definition.prompt,
723
- };
724
- }
725
-
726
725
  /**
727
726
  * The repository a match named. Every match comes from this catalog's own
728
727
  * repositories, so the lookup always finds one.
@@ -746,14 +745,22 @@ function referenceContext(inputs: CatalogInputs): ReferenceContext {
746
745
  }
747
746
 
748
747
  /**
749
- * A parsed ref written back out as a reference, without its version range: the
750
- * range says which version to use, never which thing is meant.
748
+ * A ref from the command line, checked by the one parser, and the spelling to
749
+ * search for: a short ref written back without its version range (the range
750
+ * says which version to use, never which thing is meant), or a location
751
+ * exactly as written, which the search settles through the repository's index.
751
752
  *
752
- * @param parsed - The parsed ref.
753
+ * @param ref - The ref as typed.
754
+ * @returns The short reading, when the ref was short, and the search term.
753
755
  */
754
- function refSearchString(parsed: ParsedRef): string {
755
- const path = parsed.recipe === undefined ? parsed.namespace : `${parsed.namespace}/${parsed.recipe}`;
756
- return parsed.repo === undefined ? path : `${parsed.repo}:${path}`;
756
+ function readCommandLineRef(ref: string): { parsed?: ParsedRef; search: string } {
757
+ const readings = parseRef(ref);
758
+ const first = readings[0]!;
759
+ if (first.location !== undefined || !isNamedReading(first)) return { search: ref.trim() };
760
+ return {
761
+ parsed: first,
762
+ search: first.repo === undefined ? refKey(first) : `${first.repo}:${refKey(first)}`,
763
+ };
757
764
  }
758
765
 
759
766
  /** The error a ref that could have meant several things raises. */