@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
@@ -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,16 +28,20 @@ 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
- dependencyRefKey,
31
- dependencyRepoUrl,
32
34
  formatRef,
33
- parseDependencyRef,
35
+ isBrowsedReading,
36
+ isNamedReading,
37
+ parseRef,
34
38
  refKey,
35
- type DependencyRef,
39
+ splitRecipeKey,
36
40
  type ParsedRef,
37
- } from "./ref.js";
41
+ type RefReading,
42
+ } from "../refs/parse.js";
43
+ import { settleBrowsed, settleDependency, settleInIndex } from "../refs/settle.js";
44
+ import { RefSource } from "../refs/scopes.js";
38
45
  import { shortNameFromIdentity } from "./identity.js";
39
46
  import type { IndexDependency } from "./formats/index-file.js";
40
47
 
@@ -78,8 +85,9 @@ export type ResolverRepo = {
78
85
 
79
86
  /**
80
87
  * Loads a resolved recipe's manifest, which is what names its dependencies.
81
- * Returning undefined means "not available", and the recipe is reported under
82
- * `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.
83
91
  */
84
92
  export type RecipeManifestLoader = (
85
93
  recipe: ResolvedRecipe
@@ -138,6 +146,17 @@ export type ResolvedRecipe = {
138
146
  * published against rather than at whatever its range would reach today.
139
147
  */
140
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;
141
160
  };
142
161
 
143
162
  /** A repository something needs that the project has not added. */
@@ -163,7 +182,10 @@ export type ResolveResult = {
163
182
  resolved: ResolvedRecipe[];
164
183
  /** Repositories a dependency needs that the project has not added. */
165
184
  missingRepos: MissingRepo[];
166
- /** 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
+ */
167
189
  missingManifests: string[];
168
190
  /** Any dependency cycle found, as the chain of recipe keys forming it. */
169
191
  cycles: string[][];
@@ -186,6 +208,15 @@ type WorkItem = RefRequest & {
186
208
  provider?: string;
187
209
  /** The dependency exactly as the manifest wrote it, for messages. */
188
210
  written: string;
211
+ /**
212
+ * A browser path the dependency named, settled through the repository's
213
+ * index once the project has it. Until then `ref` holds no namespace.
214
+ */
215
+ browsed?: string;
216
+ /** The range the manifest wrote, applied once a browsed path is settled. */
217
+ range?: string;
218
+ /** What the declaring version's index records, for pins once a browsed path is settled. */
219
+ pins?: Record<string, IndexDependency>;
189
220
  };
190
221
  };
191
222
 
@@ -200,24 +231,27 @@ type WorkItem = RefRequest & {
200
231
  * version is what gets asked for, rather than whatever the declared range would
201
232
  * reach today.
202
233
  *
234
+ * A dependency that reads more than one way (a GitLab URL with nested groups)
235
+ * is settled by what the release recorded in the index, and failing that by
236
+ * what the project's own cached indexes already publish; nothing is fetched to
237
+ * find out. One that still reads several ways is an error naming them.
238
+ *
203
239
  * @param written - The dependency as the manifest wrote it.
204
240
  * @param parent - The recipe whose manifest declared it.
205
241
  * @param kind - Whether it was declared as a dependency or a co-subscription.
242
+ * @param context - The added repositories and their cached indexes.
206
243
  */
207
244
  function dependencyRequest(
208
245
  written: string,
209
246
  parent: ResolvedRecipe,
210
- kind: LockKind
247
+ kind: LockKind,
248
+ context: ResolveContext
211
249
  ): WorkItem {
212
- const parsed = parseDependencyRef(written);
213
- const pinned = parent.dependencies?.[dependencyRefKey(parsed)];
214
-
215
- const ref: ParsedRef = { namespace: parsed.namespace };
216
- if (parsed.recipe !== undefined) {
217
- ref.recipe = parsed.recipe;
218
- const range = pinned?.version ?? parsed.range ?? pinned?.range;
219
- if (range !== undefined) ref.range = range;
220
- }
250
+ const reading = settleDependency(written, {
251
+ ...(parent.dependencies === undefined ? {} : { recorded: parent.dependencies }),
252
+ known: (candidate) => knownLocally(candidate, context),
253
+ });
254
+ if (reading === undefined) throw unsettledDependencyError(written, parent);
221
255
 
222
256
  const base = {
223
257
  requestedBy: parent.key,
@@ -225,22 +259,143 @@ function dependencyRequest(
225
259
  ...(parent.prerelease ? { prerelease: true } : {}),
226
260
  };
227
261
 
228
- if (parsed.kind === "sibling") {
262
+ if (isBrowsedReading(reading)) {
263
+ return {
264
+ ref: { namespace: "" },
265
+ ...base,
266
+ remote: {
267
+ ...remoteOf(reading, written),
268
+ browsed: reading.browsed,
269
+ ...(reading.range === undefined ? {} : { range: reading.range }),
270
+ ...(parent.dependencies === undefined ? {} : { pins: parent.dependencies }),
271
+ },
272
+ };
273
+ }
274
+ /* c8 ignore next */
275
+ if (!isNamedReading(reading)) throw unsettledDependencyError(written, parent);
276
+
277
+ const pinned = parent.dependencies?.[refKey(reading)];
278
+ const ref = pinnedRef(reading, pinned);
279
+
280
+ if (reading.location === undefined) {
229
281
  return { ref: { ...ref, repo: parent.repo }, ...base };
230
282
  }
231
283
 
284
+ const remote = remoteOf(reading, written);
232
285
  return {
233
286
  ref,
234
287
  ...base,
235
- remote: {
236
- identity: pinned?.repo ?? parsed.canonicalRepo!,
237
- url: dependencyRepoUrl(parsed)!,
238
- ...(parsed.provider === undefined ? {} : { provider: parsed.provider }),
239
- written: written.trim(),
240
- },
288
+ remote: { ...remote, identity: pinned?.repo ?? remote.identity },
241
289
  };
242
290
  }
243
291
 
292
+ /**
293
+ * A named reading as a request asks for it: at the exact version the index
294
+ * recorded when it recorded one, otherwise at the range the manifest wrote.
295
+ *
296
+ * @param reading - The reading.
297
+ * @param pinned - What the declaring version's index recorded for it.
298
+ */
299
+ function pinnedRef(reading: ParsedRef, pinned: IndexDependency | undefined): ParsedRef {
300
+ const ref: ParsedRef = { namespace: reading.namespace };
301
+ if (reading.recipe !== undefined) {
302
+ ref.recipe = reading.recipe;
303
+ const range = pinned?.version ?? reading.range ?? pinned?.range;
304
+ if (range !== undefined) ref.range = range;
305
+ }
306
+ return ref;
307
+ }
308
+
309
+ /** Where a located reading's repository lives, as a request carries it. */
310
+ function remoteOf(
311
+ reading: RefReading,
312
+ written: string
313
+ ): NonNullable<WorkItem["remote"]> {
314
+ const location = reading.location!;
315
+ return {
316
+ identity: location.identity,
317
+ url: location.url,
318
+ provider: location.provider,
319
+ written: written.trim(),
320
+ };
321
+ }
322
+
323
+ /**
324
+ * Whether a reading's repository is one the project added and its cached index
325
+ * publishes what the reading names. It reads only what is already here.
326
+ *
327
+ * @param reading - One reading of a dependency.
328
+ * @param context - The added repositories and their cached indexes.
329
+ */
330
+ function knownLocally(reading: RefReading, context: ResolveContext): boolean {
331
+ if (reading.location === undefined) return false;
332
+ const added = repoNamedByIdentity(context.repos, reading.location.identity);
333
+ const index = added === undefined ? undefined : context.indexes.get(added);
334
+ if (index === undefined) return false;
335
+ return settleInIndex(reading, { namespaces: Object.keys(index.namespaces), recipes: index.recipes }).length > 0;
336
+ }
337
+
338
+ /**
339
+ * The error for a dependency that reads more than one way with nothing to
340
+ * settle it.
341
+ *
342
+ * @param written - The dependency as written.
343
+ * @param parent - The recipe that declared it.
344
+ */
345
+ function unsettledDependencyError(written: string, parent: ResolvedRecipe): ConfigError {
346
+ const readings = parseRef(written, RefSource.Manifest);
347
+ return new ConfigError(
348
+ `The dependency '${written.trim()}' of the recipe '${parent.key}' can be read ` +
349
+ `${readings.length} ways, and nothing says which one it means:\n` +
350
+ readings.map((reading) => ` ${formatRef(reading)}`).join("\n") +
351
+ `\n A release records which one it settled on in the index; the index of ` +
352
+ `'${parent.repo}' records none for this version. Release the recipe again with this ` +
353
+ `version of sous, or write the dependency so it reads one way, as one of the forms above.`
354
+ );
355
+ }
356
+
357
+ /**
358
+ * Settles a browsed path, once the project has the repository it names.
359
+ *
360
+ * @param item - The request, whose `ref` is filled in.
361
+ * @param index - The repository's cached index.
362
+ */
363
+ function settleBrowsedItem(item: WorkItem, index: IndexFile | undefined): void {
364
+ const remote = item.remote!;
365
+ const settled =
366
+ index === undefined
367
+ ? []
368
+ : settleBrowsed(remote.browsed!, {
369
+ namespaces: Object.keys(index.namespaces),
370
+ recipes: index.recipes,
371
+ });
372
+
373
+ let found: ParsedRef | undefined = settled.length === 1 ? settled[0] : undefined;
374
+ if (found === undefined) {
375
+ // The folder may have moved since the release; the key it settled on then
376
+ // is recorded beside the repository it lives in.
377
+ const recorded = Object.keys(remote.pins ?? {}).filter(
378
+ (key) => remote.pins![key]!.repo === remote.identity
379
+ );
380
+ if (recorded.length === 1) {
381
+ const { namespace, name } = splitRecipeKey(recorded[0]!);
382
+ found = { namespace, recipe: name };
383
+ }
384
+ }
385
+
386
+ if (found === undefined) {
387
+ throw new ConfigError(
388
+ `The dependency '${remote.written}' names a folder, and the index of the repository at ` +
389
+ `${remote.url} ${settled.length === 0 ? "publishes no recipe there" : "publishes several recipes there"}.` +
390
+ `\n It was required by the recipe '${item.requestedBy}'. Write the dependency as the ` +
391
+ `recipe it means, such as '${remote.url}/<namespace>/<recipe>'.`
392
+ );
393
+ }
394
+
395
+ const withRange = { ...found, ...(remote.range === undefined ? {} : { range: remote.range }) };
396
+ item.ref = { ...pinnedRef(withRange, remote.pins?.[refKey(found)]), repo: item.ref.repo };
397
+ }
398
+
244
399
  /**
245
400
  * The short name of the added repository with this identity, or undefined when
246
401
  * the project has added none. Short names are a project's own labels, so the
@@ -249,7 +404,7 @@ function dependencyRequest(
249
404
  * @param repos - The repositories the project has added.
250
405
  * @param identity - The canonical identity to look for.
251
406
  */
252
- function repoNamedByIdentity(
407
+ export function repoNamedByIdentity(
253
408
  repos: Record<string, ResolverRepo>,
254
409
  identity: string
255
410
  ): string | undefined {
@@ -266,7 +421,7 @@ function repoNamedByIdentity(
266
421
  * @param repos - The repositories the project has added.
267
422
  * @param identity - The canonical identity of the repository being named.
268
423
  */
269
- function proposeRepoName(
424
+ export function proposeRepoName(
270
425
  repos: Record<string, ResolverRepo>,
271
426
  identity: string
272
427
  ): string {
@@ -334,6 +489,7 @@ export async function resolveRefs(
334
489
  continue;
335
490
  }
336
491
  item.ref = { ...item.ref, repo: added };
492
+ if (item.remote.browsed !== undefined) settleBrowsedItem(item, context.indexes.get(added));
337
493
  }
338
494
 
339
495
  if (item.ref.recipe === undefined) {
@@ -358,18 +514,18 @@ export async function resolveRefs(
358
514
  }
359
515
  walked.set(recipe.key, recipe.version);
360
516
 
361
- const manifest = await context.loadManifest(recipe);
362
- if (manifest === undefined) {
517
+ const lists = dependencyListsOf(recipe, await context.loadManifest(recipe));
518
+ if (lists === undefined) {
363
519
  missingManifests.add(recipe.key);
364
520
  continue;
365
521
  }
366
522
 
367
523
  for (const [kind, refs] of [
368
- ["depends", manifest.depends ?? []],
369
- ["subscribes", manifest.subscribes ?? []],
524
+ ["depends", lists.depends],
525
+ ["subscribes", lists.subscribes],
370
526
  ] as Array<[LockKind, string[]]>) {
371
527
  for (const written of refs) {
372
- queue.push(dependencyRequest(written, recipe, kind));
528
+ queue.push(dependencyRequest(written, recipe, kind, context));
373
529
  }
374
530
  }
375
531
  }
@@ -456,8 +612,9 @@ async function keepOnlyReachable(
456
612
  } catch {
457
613
  manifest = undefined;
458
614
  }
615
+ const lists = dependencyListsOf(recipe, manifest);
459
616
 
460
- if (manifest === undefined) {
617
+ if (lists === undefined) {
461
618
  // An unreadable manifest is already reported as a missing manifest. Keep
462
619
  // everything this recipe held rather than dropping a dependency over it.
463
620
  for (const other of resolved.values()) {
@@ -469,24 +626,11 @@ async function keepOnlyReachable(
469
626
  }
470
627
 
471
628
  for (const [kind, refs] of [
472
- ["depends", manifest.depends ?? []],
473
- ["subscribes", manifest.subscribes ?? []],
629
+ ["depends", lists.depends],
630
+ ["subscribes", lists.subscribes],
474
631
  ] as Array<[LockKind, string[]]>) {
475
632
  for (const written of refs) {
476
- let parsed: DependencyRef;
477
- try {
478
- parsed = parseDependencyRef(written);
479
- } catch {
480
- continue;
481
- }
482
- // A namespace ref means every recipe in it, exactly as the walk expanded it.
483
- const targets =
484
- parsed.recipe === undefined
485
- ? [...resolved.keys()].filter((entry) =>
486
- entry.startsWith(`${parsed.namespace}/`)
487
- )
488
- : [dependencyRefKey(parsed)];
489
- for (const target of targets) {
633
+ for (const target of dependencyTargets(written, recipe, context, resolved)) {
490
634
  if (!resolved.has(target)) continue;
491
635
  if (declare(key, target, kind)) queue.push(target);
492
636
  }
@@ -506,6 +650,57 @@ async function keepOnlyReachable(
506
650
  }
507
651
  }
508
652
 
653
+ /**
654
+ * The resolved keys one dependency of a recipe reaches, the same way the walk
655
+ * reached them: a namespace means every recipe in it, and a browsed path means
656
+ * the recipe it settles on. A dependency that no longer settles reaches
657
+ * nothing, because the walk would have failed on it.
658
+ *
659
+ * @param written - The dependency as the manifest wrote it.
660
+ * @param holder - The recipe that declared it.
661
+ * @param context - The added repositories and their cached indexes.
662
+ * @param resolved - What the walk resolved.
663
+ */
664
+ function dependencyTargets(
665
+ written: string,
666
+ holder: ResolvedRecipe,
667
+ context: ResolveContext,
668
+ resolved: Map<string, ResolvedRecipe>
669
+ ): string[] {
670
+ let item: WorkItem;
671
+ try {
672
+ item = dependencyRequest(written, holder, "depends", context);
673
+ if (item.remote?.browsed !== undefined) {
674
+ const added = repoNamedByIdentity(context.repos, item.remote.identity);
675
+ settleBrowsedItem(item, added === undefined ? undefined : context.indexes.get(added));
676
+ }
677
+ } catch {
678
+ return [];
679
+ }
680
+ const { namespace } = item.ref;
681
+ return item.ref.recipe === undefined
682
+ ? [...resolved.keys()].filter((entry) => entry.startsWith(`${namespace}/`))
683
+ : [refKey(item.ref)];
684
+ }
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
+
509
704
  /** Records one more reason a repository is needed. */
510
705
  function recordMissingRepo(into: Map<string, MissingRepo>, missing: MissingRepo): void {
511
706
  const existing = into.get(missing.name);
@@ -618,15 +813,16 @@ function resolveRecipeRef(
618
813
 
619
814
  const version = pickVersion(key, entry, ranges, prerelease, context.keep?.[key]);
620
815
  const versionEntry = entry.versions[version]!;
816
+ const declares = indexDependencyLists(versionEntry);
621
817
 
622
818
  const requestedBy = [...(previous?.requestedBy ?? [])];
623
819
  if (!requestedBy.includes(item.requestedBy)) requestedBy.push(item.requestedBy);
624
820
 
625
- const namespace = key.slice(0, key.indexOf("/"));
821
+ const { namespace, name } = splitRecipeKey(key);
626
822
  return {
627
823
  key,
628
824
  namespace,
629
- name: key.slice(namespace.length + 1),
825
+ name,
630
826
  repo: chosen.repo,
631
827
  identity: context.repos[chosen.repo]?.identity ?? chosen.repo,
632
828
  version,
@@ -636,6 +832,8 @@ function resolveRecipeRef(
636
832
  ...(versionEntry.dependencies === undefined
637
833
  ? {}
638
834
  : { dependencies: versionEntry.dependencies }),
835
+ ...(versionEntry.variables === undefined ? {} : { variables: versionEntry.variables }),
836
+ ...(declares === undefined ? {} : { declares }),
639
837
  // A recipe held as a co-subscription by anyone is a co-subscription; a
640
838
  // build dependency only stays one while nothing subscribes to it.
641
839
  kind: previous?.kind === "subscribes" || item.kind === "subscribes" ? "subscribes" : "depends",