@sous-io/sous 0.2.27 → 0.2.29

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 (43) hide show
  1. package/docs/markdown/_sidebar.md +1 -0
  2. package/docs/markdown/repositories-authoring.md +12 -8
  3. package/docs/markdown/repositories-consuming.md +8 -1
  4. package/docs/markdown/repositories-file-formats.md +53 -8
  5. package/docs/markdown/repositories-providers.md +8 -6
  6. package/docs/markdown/repositories-troubleshooting.md +6 -0
  7. package/docs/markdown/repositories-variables.md +9 -2
  8. package/docs/markdown/repositories.md +3 -3
  9. package/package.json +1 -1
  10. package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +11 -7
  11. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  12. package/src/commands/lock/rebuild.ts +2 -2
  13. package/src/commands/repo/contribute.ts +1 -10
  14. package/src/commands/repo/gc.ts +3 -2
  15. package/src/commands/repo/release.ts +20 -3
  16. package/src/commands/repo/search.ts +2 -1
  17. package/src/commands/vars/ask.ts +32 -10
  18. package/src/lib/refs/find.ts +202 -21
  19. package/src/lib/refs/index.ts +2 -0
  20. package/src/lib/refs/parse.ts +844 -0
  21. package/src/lib/refs/scopes.ts +34 -0
  22. package/src/lib/refs/settle.ts +219 -0
  23. package/src/lib/repos/catalog.ts +44 -25
  24. package/src/lib/repos/formats/index-file.ts +2 -1
  25. package/src/lib/repos/formats/recipe-manifest.ts +10 -8
  26. package/src/lib/repos/index.ts +0 -1
  27. package/src/lib/repos/lock-service.ts +3 -2
  28. package/src/lib/repos/locked-namespace-resolver.ts +21 -15
  29. package/src/lib/repos/locked-recipes.ts +2 -2
  30. package/src/lib/repos/namespace-resolver.ts +40 -47
  31. package/src/lib/repos/providers/base.ts +30 -0
  32. package/src/lib/repos/providers/github.ts +22 -0
  33. package/src/lib/repos/providers/gitlab.ts +68 -0
  34. package/src/lib/repos/providers/provider.ts +96 -0
  35. package/src/lib/repos/release/index-builder.ts +55 -17
  36. package/src/lib/repos/release/index.ts +1 -0
  37. package/src/lib/repos/release/plan.ts +8 -6
  38. package/src/lib/repos/release/settle.ts +237 -0
  39. package/src/lib/repos/release/tags.ts +3 -7
  40. package/src/lib/repos/resolver.ts +198 -41
  41. package/src/lib/repos/subscription-service.ts +262 -38
  42. package/src/lib/vars/mappings.ts +17 -30
  43. package/src/lib/repos/ref.ts +0 -513
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { namespaceOfKey } from "../refs/parse.js";
2
3
 
3
4
  /**
4
5
  * Namespace addressability for templates: the reserved `~` include sigil.
@@ -202,7 +203,7 @@ function describeDropped(
202
203
  dropped: DroppedRecipe,
203
204
  opts: { namespace: string; rest: string }
204
205
  ): string[] {
205
- const recipe = `${opts.namespace}/${opts.rest.split("/")[0] ?? ""}`;
206
+ const { recipe } = splitIncludePath(opts.namespace, opts.rest);
206
207
  if (dropped.by === "disabled-subscription") {
207
208
  return [
208
209
  `This project subscribes to "${dropped.subscription}", but that subscription is ` +
@@ -232,14 +233,15 @@ export type StaticNamespaceResolverOptions = {
232
233
  */
233
234
  recipes: Record<string, string>;
234
235
  /**
235
- * What each recipe declares, mapping `<namespace>/<recipe>` to the refs it
236
- * may address. An entry may be a full ref (`workflow/task-files`) or a bare
237
- * namespace (`workflow`, meaning every recipe in it). A recipe with no entry
238
- * declares nothing and may address only itself.
236
+ * What each recipe declares, mapping `<namespace>/<recipe>` to the keys it
237
+ * may address. An entry is a recipe key (`workflow/task-files`) or a bare
238
+ * namespace (`workflow`, meaning every recipe in it), already settled from
239
+ * however the manifest wrote it. A recipe with no entry declares nothing and
240
+ * may address only itself.
239
241
  */
240
242
  dependencies?: Record<string, string[]>;
241
243
  /**
242
- * What the project's own templates may address, in the same ref forms as
244
+ * What the project's own templates may address, as the same kind of keys as
243
245
  * `dependencies`. Omit it to make every known recipe addressable from
244
246
  * project templates, which is the rule for a real project: its templates may
245
247
  * address everything its lockfile pins.
@@ -293,7 +295,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
293
295
  private knownNamespaces(): string[] {
294
296
  const names = new Set<string>();
295
297
  for (const ref of Object.keys(this.recipes)) {
296
- names.add(ref.split("/")[0]);
298
+ names.add(namespaceOfKey(ref));
297
299
  }
298
300
  return [...names].sort();
299
301
  }
@@ -301,7 +303,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
301
303
  /** Every known recipe ref inside one namespace, sorted. */
302
304
  private recipesIn(namespace: string): string[] {
303
305
  return Object.keys(this.recipes)
304
- .filter((ref) => ref.split("/")[0] === namespace)
306
+ .filter((ref) => namespaceOfKey(ref) === namespace)
305
307
  .sort();
306
308
  }
307
309
 
@@ -329,9 +331,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
329
331
  resolve(request: NamespaceRequest): NamespaceResolution {
330
332
  const { namespace, rest, fromFile } = request;
331
333
 
332
- const segments = rest.split("/").filter((segment) => segment.length > 0);
333
- const recipeName = segments[0] ?? "";
334
- const ref = `${namespace}/${recipeName}`;
334
+ const { recipe: ref, segments } = splitIncludePath(namespace, rest);
335
335
 
336
336
  if (!this.knownNamespaces().includes(namespace)) {
337
337
  return {
@@ -361,7 +361,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
361
361
  return { kind: "not-a-dependency", recipe: ref, includingRecipe };
362
362
  }
363
363
 
364
- const inner = segments.slice(1).join("/");
364
+ const inner = segments.join("/");
365
365
  const resolved = path.resolve(recipeDir, inner);
366
366
 
367
367
  // A `~namespace` reference addresses a recipe's own files. Without this the
@@ -370,7 +370,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
370
370
  // segment check catches the written form, and the relative check catches
371
371
  // everything else, including an absolute inner path and any symlink-free
372
372
  // route out that normalisation would otherwise hide.
373
- if (escapesRecipe(recipeDir, segments.slice(1), inner, resolved)) {
373
+ if (escapesRecipe(recipeDir, segments, inner, resolved)) {
374
374
  return { kind: "escapes-recipe", recipe: ref, reference: `${namespace}/${rest}` };
375
375
  }
376
376
 
@@ -378,6 +378,27 @@ export class StaticNamespaceResolver implements NamespaceResolver {
378
378
  }
379
379
  }
380
380
 
381
+ /**
382
+ * Takes the part of a `@~namespace/...` include line after the namespace apart:
383
+ * its first segment names the recipe, and the rest is a file path inside it.
384
+ * This is an include path, not a ref; it is the one place such a path is split.
385
+ *
386
+ * splitIncludePath("workflow", "task-files/_partials/resume.md")
387
+ * // -> { recipe: "workflow/task-files", segments: ["_partials", "resume.md"] }
388
+ *
389
+ * @param namespace - The namespace the include line named.
390
+ * @param rest - Everything after `~<namespace>/`.
391
+ */
392
+ function splitIncludePath(
393
+ namespace: string,
394
+ rest: string
395
+ ): { recipe: string; segments: string[] } {
396
+ const [recipeName = "", ...segments] = rest
397
+ .split("/")
398
+ .filter((segment) => segment.length > 0);
399
+ return { recipe: `${namespace}/${recipeName}`, segments };
400
+ }
401
+
381
402
  /**
382
403
  * Whether an inner path would address something outside the recipe directory.
383
404
  *
@@ -401,46 +422,18 @@ function escapesRecipe(
401
422
  }
402
423
 
403
424
  /**
404
- * Strip the decorations a written reference may carry so it can be compared to
405
- * a plain `<namespace>/<recipe>` ref: a repository qualifier (a subscription's
406
- * `sous-public:misc/stuff`, or a manifest's
407
- * `github://owner/repo/misc/stuff` locator) and a trailing version range
408
- * (`misc/stuff@^1.2`).
409
- *
410
- * @param ref - A reference as written in a manifest or subscription entry.
411
- * @returns The bare `<namespace>` or `<namespace>/<recipe>` form.
412
- */
413
- export function normalizeRef(ref: string): string {
414
- const trimmed = ref.trim();
415
-
416
- // A locator URL names the repository first and the recipe last, so the two
417
- // trailing segments are the ref; everything before them is where it lives.
418
- const scheme = trimmed.indexOf("://");
419
- const body =
420
- scheme === -1
421
- ? trimmed.includes(":")
422
- ? trimmed.slice(trimmed.indexOf(":") + 1)
423
- : trimmed
424
- : trimmed.slice(scheme + 3).split("/").slice(-2).join("/");
425
-
426
- const at = body.lastIndexOf("@");
427
- return (at > 0 ? body.slice(0, at) : body).trim();
428
- }
429
-
430
- /**
431
- * Whether a list of declared refs covers a recipe, either by naming the recipe
432
- * itself or by naming its whole namespace.
425
+ * Whether a list of declared keys covers a recipe, either by naming the recipe
426
+ * itself or by naming its whole namespace. The keys are already settled
427
+ * (`namespace` or `namespace/recipe`): whatever form a manifest wrote a
428
+ * dependency in, the lockfile records the key it resolved to.
433
429
  *
434
- * @param declared - Declared refs (dependencies, or the project's subscriptions).
430
+ * @param declared - Declared keys (dependencies, or the project's subscriptions).
435
431
  * @param namespace - The namespace being addressed.
436
432
  * @param ref - The fully qualified recipe ref being addressed.
437
433
  * @returns True when the reference is in scope.
438
434
  */
439
435
  function declaresRef(declared: string[], namespace: string, ref: string): boolean {
440
- return declared.some((entry) => {
441
- const normalized = normalizeRef(entry);
442
- return normalized === ref || normalized === namespace;
443
- });
436
+ return declared.some((entry) => entry === ref || entry === namespace);
444
437
  }
445
438
 
446
439
  /**
@@ -37,6 +37,8 @@ import type {
37
37
  ProviderId,
38
38
  ProviderOptions,
39
39
  RepoProvider,
40
+ LocationReading,
41
+ WrittenLocation,
40
42
  } from "./provider.js";
41
43
 
42
44
  /**
@@ -68,6 +70,34 @@ export abstract class ProviderBase implements RepoProvider {
68
70
  options?: ProviderOptions
69
71
  ): Promise<void>;
70
72
 
73
+ // --- Reading a location ----------------------------------------------------
74
+
75
+ /** This provider's public host, declared by a provider that has one. */
76
+ declare readonly defaultHost?: string;
77
+
78
+ /**
79
+ * No readings: a provider that does not override this reads no location
80
+ * inside its repositories, so a ref naming one is refused with a sentence.
81
+ *
82
+ * @param _location - The written location; unused.
83
+ */
84
+ readLocation(_location: WrittenLocation): LocationReading[] {
85
+ return [];
86
+ }
87
+
88
+ /**
89
+ * `<id>://<host>/<repoPath>/<rest>`, with the host left out when it is this
90
+ * provider's default.
91
+ *
92
+ * @param host - The repository's host.
93
+ * @param repoPath - The repository's path on that host.
94
+ * @param rest - What is named inside it.
95
+ */
96
+ formatLocator(host: string, repoPath: string, rest: string): string {
97
+ const where = host === this.defaultHost ? repoPath : `${host}/${repoPath}`;
98
+ return `${this.id}://${where}${rest.length === 0 ? "" : `/${rest}`}`;
99
+ }
100
+
71
101
  // --- Subprocess plumbing ---------------------------------------------------
72
102
 
73
103
  /**
@@ -24,7 +24,11 @@ import { fetchText, type FetchLike } from "./http.js";
24
24
  import {
25
25
  buildCanonicalRepo,
26
26
  invalidRepoUrl,
27
+ readingsAfterRepository,
27
28
  splitRepoUrl,
29
+ withoutGitSuffix,
30
+ type LocationReading,
31
+ type WrittenLocation,
28
32
  type AuthStatus,
29
33
  type CanonicalRepo,
30
34
  type ChangeProposal,
@@ -83,6 +87,24 @@ export class GithubProvider extends ProviderBase {
83
87
  /** What GitHub calls a proposal. */
84
88
  readonly proposalNoun = "pull request";
85
89
 
90
+ /** The host a `github://` locator means when it names none. */
91
+ readonly defaultHost = GITHUB_HOST;
92
+
93
+ /**
94
+ * A GitHub repository is always an owner and a name, so the first two
95
+ * segments are the repository and everything after them names something
96
+ * inside it: a namespace, a recipe, or a browser path after `tree/` or
97
+ * `blob/`.
98
+ *
99
+ * @param location - The host and the segments after it.
100
+ */
101
+ readLocation(location: WrittenLocation): LocationReading[] {
102
+ const { segments } = location;
103
+ if (segments.length < 2) return [];
104
+ const repoPath = `${segments[0]}/${withoutGitSuffix(segments[1]!)}`;
105
+ return readingsAfterRepository(repoPath, segments.slice(2));
106
+ }
107
+
86
108
  matches(url: string): boolean {
87
109
  const parts = splitRepoUrl(url);
88
110
  return parts !== undefined && parts.host === GITHUB_HOST;
@@ -28,7 +28,11 @@ import { fetchText, type FetchLike } from "./http.js";
28
28
  import {
29
29
  buildCanonicalRepo,
30
30
  invalidRepoUrl,
31
+ readingsAfterRepository,
31
32
  splitRepoUrl,
33
+ withoutGitSuffix,
34
+ type LocationReading,
35
+ type WrittenLocation,
32
36
  type AuthStatus,
33
37
  type CanonicalRepo,
34
38
  type ChangeProposal,
@@ -80,6 +84,70 @@ export class GitlabProvider extends ProviderBase {
80
84
  /** What GitLab calls a proposal. */
81
85
  readonly proposalNoun = "merge request";
82
86
 
87
+ /** The host a `gitlab://` locator means when it names none. */
88
+ readonly defaultHost = GITLAB_HOST;
89
+
90
+ /**
91
+ * A GitLab project path nests (`group/subgroup/project`), so where it ends is
92
+ * only certain when the location says so: a `-` segment (GitLab's own
93
+ * separator, as in `/-/tree/main/...`) or a `.git` suffix ends it. Otherwise
94
+ * every split that leaves a project of at least two segments and at most a
95
+ * namespace and a recipe after it is a reading, and the caller keeps the one
96
+ * an index confirms.
97
+ *
98
+ * readLocation({ host: "gitlab.com", segments: ["a", "b", "c", "d"] });
99
+ * // -> project "a/b" naming "c/d", project "a/b/c" naming "d",
100
+ * // and project "a/b/c/d" naming nothing
101
+ *
102
+ * @param location - The host and the segments after it.
103
+ */
104
+ readLocation(location: WrittenLocation): LocationReading[] {
105
+ const { segments } = location;
106
+
107
+ const separator = segments.indexOf("-");
108
+ if (separator >= 2) {
109
+ return readingsAfterRepository(
110
+ this.projectPath(segments.slice(0, separator)),
111
+ segments.slice(separator)
112
+ );
113
+ }
114
+
115
+ const suffixed = segments.findIndex((segment) => /\.git$/i.test(segment));
116
+ if (suffixed >= 1) {
117
+ return readingsAfterRepository(
118
+ this.projectPath(segments.slice(0, suffixed + 1)),
119
+ segments.slice(suffixed + 1)
120
+ );
121
+ }
122
+
123
+ const readings: LocationReading[] = [];
124
+ for (let end = 2; end <= segments.length; end++) {
125
+ const rest = segments.slice(end);
126
+ if (rest.length > 2) continue;
127
+ readings.push({ repoPath: this.projectPath(segments.slice(0, end)), named: rest });
128
+ }
129
+ return readings;
130
+ }
131
+
132
+ /**
133
+ * The canonical locator always marks where the project path ends with `-`,
134
+ * because without it a nested group reads more than one way.
135
+ *
136
+ * @param host - The project's host.
137
+ * @param repoPath - The project's path on that host.
138
+ * @param rest - What is named inside it.
139
+ */
140
+ formatLocator(host: string, repoPath: string, rest: string): string {
141
+ const where = host === this.defaultHost ? repoPath : `${host}/${repoPath}`;
142
+ return `${this.id}://${where}${rest.length === 0 ? "" : `/-/${rest}`}`;
143
+ }
144
+
145
+ /** A project path from its segments, with the last one's `.git` gone. */
146
+ private projectPath(segments: string[]): string {
147
+ const last = segments.length - 1;
148
+ return segments.map((segment, index) => (index === last ? withoutGitSuffix(segment) : segment)).join("/");
149
+ }
150
+
83
151
  matches(url: string): boolean {
84
152
  const parts = splitRepoUrl(url);
85
153
  if (parts === undefined) return false;
@@ -56,6 +56,44 @@ export type CanonicalRepo = {
56
56
  sshUrl: string;
57
57
  };
58
58
 
59
+ /**
60
+ * A location as written in a ref, taken apart only as far as every host agrees:
61
+ * the host, and the path segments after it. Which of those segments are the
62
+ * repository and which name something inside it is a host's own rule, so a
63
+ * provider answers that (see `RepoProvider.readLocation`).
64
+ *
65
+ * https://gitlab.com/group/sub/project/-/tree/main/recipes/workflow/alpha
66
+ * // -> { host: "gitlab.com",
67
+ * // segments: ["group", "sub", "project", "-", "tree", "main", ...] }
68
+ */
69
+ export type WrittenLocation = {
70
+ /** The host, lowercased, such as `github.com`. */
71
+ host: string;
72
+ /** Every path segment after the host, in order, none of them empty. */
73
+ segments: string[];
74
+ };
75
+
76
+ /**
77
+ * One way a provider reads a written location. A location on a host where a
78
+ * repository path can be any length (a GitLab group nests) may have several.
79
+ */
80
+ export type LocationReading = {
81
+ /** The repository's path on the host, such as `sous-io/sous-recipes`, with no `.git`. */
82
+ repoPath: string;
83
+ /**
84
+ * The segments after the repository that name something inside it: a
85
+ * namespace, a namespace and a recipe, or a namespace and `*`. Empty when the
86
+ * location names only the repository.
87
+ */
88
+ named?: string[];
89
+ /**
90
+ * A browser URL's path inside the repository, copied from a host's file view
91
+ * (after `tree/` or `blob/`), with the branch still in front of it. It is
92
+ * settled against the `path` each recipe's index entry records.
93
+ */
94
+ browsed?: string;
95
+ };
96
+
59
97
  /** Options every provider call accepts, all of them for testing seams. */
60
98
  export type ProviderOptions = {
61
99
  /**
@@ -215,6 +253,27 @@ export interface RepoProvider {
215
253
  readonly id: ProviderId;
216
254
  /** What this provider can do; see ProviderFeature. */
217
255
  readonly features: ProviderFeature[];
256
+ /**
257
+ * The host a locator such as `github://owner/repo/...` means when it names
258
+ * none. A provider without a public host of its own leaves it out.
259
+ */
260
+ readonly defaultHost?: string;
261
+ /**
262
+ * Every way this provider reads a location inside one of its repositories:
263
+ * where the repository path ends, and whether the rest names a namespace, a
264
+ * recipe or a browser path. An empty list means the provider reads no such
265
+ * location.
266
+ */
267
+ readLocation(location: WrittenLocation): LocationReading[];
268
+ /**
269
+ * The canonical locator for something inside one of this provider's
270
+ * repositories, which is how sous prints a located ref.
271
+ *
272
+ * @param host - The repository's host.
273
+ * @param repoPath - The repository's path on that host.
274
+ * @param rest - What is named inside it, such as `workflow/alpha`.
275
+ */
276
+ formatLocator(host: string, repoPath: string, rest: string): string;
218
277
  /** True when this provider handles the given repository URL. */
219
278
  matches(url: string): boolean;
220
279
  /** Takes a repository URL apart, raising a ConfigError when it does not fit. */
@@ -339,6 +398,43 @@ export function supportsSubmit(provider: RepoProvider): provider is SubmitCapabl
339
398
  );
340
399
  }
341
400
 
401
+ /** The words a host's file view puts between a repository and a path: `tree/` and `blob/`. */
402
+ const BROWSE_WORDS = new Set(["tree", "blob"]);
403
+
404
+ /**
405
+ * A repository's last path segment without a `.git` suffix.
406
+ *
407
+ * @param segment - The segment as written.
408
+ */
409
+ export function withoutGitSuffix(segment: string): string {
410
+ return segment.replace(/\.git$/i, "");
411
+ }
412
+
413
+ /**
414
+ * Every reading of the segments after a repository: what they name, and, when
415
+ * they start with `tree/` or `blob/`, the browser path they could also be. Both
416
+ * are returned when both are possible (a namespace could be called `tree`), and
417
+ * the caller keeps the one the repository's index confirms. A leading `-`
418
+ * segment (GitLab's separator between a project and its pages) is dropped
419
+ * first, wherever it is written.
420
+ *
421
+ * readingsAfterRepository("o/r", ["tree", "main", "recipes", "workflow", "alpha"]);
422
+ * // -> [{ repoPath: "o/r", browsed: "main/recipes/workflow/alpha" },
423
+ * // { repoPath: "o/r", named: ["tree", "main", "recipes", "workflow", "alpha"] }]
424
+ *
425
+ * @param repoPath - The repository's path on its host.
426
+ * @param rest - The segments after it.
427
+ */
428
+ export function readingsAfterRepository(repoPath: string, rest: string[]): LocationReading[] {
429
+ const after = rest[0] === "-" ? rest.slice(1) : rest;
430
+ const readings: LocationReading[] = [];
431
+ if (after.length >= 2 && BROWSE_WORDS.has(after[0]!.toLowerCase())) {
432
+ readings.push({ repoPath, browsed: after.slice(1).join("/") });
433
+ }
434
+ readings.push({ repoPath, named: after });
435
+ return readings;
436
+ }
437
+
342
438
  /**
343
439
  * Normalizes a repository URL for matching: trims it, drops a trailing slash
344
440
  * and a trailing `.git`, and lowercases the scheme and host only.
@@ -60,7 +60,9 @@ import {
60
60
  type ValidatedRecipe,
61
61
  type ValidationProblem,
62
62
  } from "./validate.js";
63
- import { parseDependencyRef } from "../ref.js";
63
+ import { isNamedReading, namespaceOfKey, parseRef, refKey } from "../../refs/parse.js";
64
+ import { RefSource } from "../../refs/scopes.js";
65
+ import type { SettledDependency } from "./settle.js";
64
66
 
65
67
  /** A version that is ready to publish but has no tag yet. */
66
68
  export type PendingRelease = {
@@ -113,6 +115,12 @@ export type BuildIndexOptions = {
113
115
  * index that describes itself.
114
116
  */
115
117
  publishing?: Record<string, string>;
118
+ /**
119
+ * The dependencies that read more than one way, as `settleDependencyLocations`
120
+ * settled them, keyed by the dependency as written. Each is recorded under
121
+ * the keys it reached, with the repository it settled on.
122
+ */
123
+ settled?: Map<string, SettledDependency>;
116
124
  };
117
125
 
118
126
  /** The index file's absolute path in a repository. */
@@ -181,7 +189,15 @@ export async function buildIndex(
181
189
  // repository as it stood at the tag, which is what it was released against.
182
190
  for (const tag of recipeTags) {
183
191
  if (Object.hasOwn(versions, tag.version)) continue;
184
- const rebuilt = await rebuildTaggedVersion(rootDir, tag, recipe, tagsByKey, existing, run);
192
+ const rebuilt = await rebuildTaggedVersion(
193
+ rootDir,
194
+ tag,
195
+ recipe,
196
+ tagsByKey,
197
+ existing,
198
+ run,
199
+ options.settled
200
+ );
185
201
  problems.push(...rebuilt.problems);
186
202
  versions[tag.version] = {
187
203
  hash: rebuilt.hash,
@@ -197,7 +213,7 @@ export async function buildIndex(
197
213
  const hasTag = recipeTags.some((entry) => entry.tag === tagName);
198
214
  const workingHash = await hashDirectory(recipe.dir);
199
215
  const where = relativeTo(rootDir, recipe.manifestPath);
200
- const declared = declaredDependencies(recipe, validation);
216
+ const declared = declaredDependencies(recipe, validation, options.settled);
201
217
 
202
218
  // A version the index publishes must have a tag, with one exception: the
203
219
  // version the manifest declares right now, which is the one a release is in
@@ -464,10 +480,12 @@ type SiblingState = {
464
480
  *
465
481
  * @param recipe - The recipe whose dependencies are being read.
466
482
  * @param validation - The validated repository, for expanding namespace refs.
483
+ * @param settled - The dependencies that read more than one way, as the release settled them.
467
484
  */
468
485
  function declaredDependencies(
469
486
  recipe: ValidatedRecipe,
470
- validation: RepoValidation
487
+ validation: RepoValidation,
488
+ settled: Map<string, SettledDependency> = new Map()
471
489
  ): DeclaredDependencies {
472
490
  const byKey = new Map<string, DeclaredDependency>();
473
491
  const namespaces = new Set<string>();
@@ -488,25 +506,43 @@ function declaredDependencies(
488
506
  };
489
507
 
490
508
  for (const written of declared) {
491
- let parsed;
509
+ // A dependency that reads more than one way was settled by the release,
510
+ // and what it settled on is what a consumer reads instead of probing.
511
+ const answer = settled.get(written.trim());
512
+ if (answer !== undefined) {
513
+ for (const key of answer.keys) {
514
+ byKey.set(key, { kind: "remote", repo: answer.identity, range: answer.range ?? "*" });
515
+ }
516
+ continue;
517
+ }
518
+
519
+ let readings;
492
520
  try {
493
- parsed = parseDependencyRef(written);
521
+ readings = parseRef(written, RefSource.Manifest);
494
522
  } catch {
495
523
  // A dependency that does not parse is already reported by validation.
496
524
  continue;
497
525
  }
498
-
499
- if (parsed.kind === "remote") {
500
- byKey.set(`${parsed.namespace}/${parsed.recipe}`, {
501
- kind: "remote",
502
- repo: parsed.canonicalRepo!,
503
- range: parsed.range ?? "*",
504
- });
526
+ // Several readings, or a browser path, are settled above or reported by
527
+ // the settling step; there is nothing more to record here.
528
+ const parsed = readings[0]!;
529
+ if (readings.length !== 1 || !isNamedReading(parsed)) continue;
530
+
531
+ if (parsed.location !== undefined) {
532
+ // A whole namespace in another repository is read from that repository's
533
+ // own index by the consumer; only a recipe has a key to record.
534
+ if (parsed.recipe !== undefined) {
535
+ byKey.set(refKey(parsed), {
536
+ kind: "remote",
537
+ repo: parsed.location.identity,
538
+ range: parsed.range ?? "*",
539
+ });
540
+ }
505
541
  continue;
506
542
  }
507
543
 
508
544
  if (parsed.recipe !== undefined) {
509
- addSibling(`${parsed.namespace}/${parsed.recipe}`, parsed.range, true);
545
+ addSibling(refKey(parsed), parsed.range, true);
510
546
  continue;
511
547
  }
512
548
 
@@ -663,7 +699,7 @@ function checkRecordedDependencies(
663
699
  if (declared.byKey.has(name)) continue;
664
700
  // A recipe the declared namespace held when the version was published, and
665
701
  // no longer does, is history rather than a disagreement.
666
- const namespace = name.slice(0, name.indexOf("/"));
702
+ const namespace = namespaceOfKey(name);
667
703
  if (entry.repo === undefined && declared.namespaces.has(namespace)) continue;
668
704
  differences.push(
669
705
  `'${name}': the index records ${describeRecorded(entry)}, and the manifest does not ` +
@@ -741,6 +777,7 @@ type RebuiltVersion = {
741
777
  * @param tagsByKey - Every release tag, grouped by recipe.
742
778
  * @param existing - The committed index, when there is one.
743
779
  * @param run - The command runner git calls go through.
780
+ * @param settled - The dependencies that read more than one way, as the release settled them.
744
781
  */
745
782
  async function rebuildTaggedVersion(
746
783
  rootDir: string,
@@ -748,7 +785,8 @@ async function rebuildTaggedVersion(
748
785
  recipe: ValidatedRecipe,
749
786
  tagsByKey: Map<string, RecipeTag[]>,
750
787
  existing: IndexFile | undefined,
751
- run: RunOptions["run"]
788
+ run: RunOptions["run"],
789
+ settled?: Map<string, SettledDependency>
752
790
  ): Promise<RebuiltVersion> {
753
791
  return withTaggedTree(
754
792
  rootDir,
@@ -763,7 +801,7 @@ async function rebuildTaggedVersion(
763
801
  const tagged = snapshot.recipes.find((entry) => entry.key === recipe.key);
764
802
  if (tagged !== undefined) {
765
803
  const dependencies = resolveIndexDependencies(
766
- declaredDependencies(tagged, snapshot),
804
+ declaredDependencies(tagged, snapshot, settled),
767
805
  siblingState(snapshot, tagsByKey, existing)
768
806
  );
769
807
  return { hash, ...(dependencies === undefined ? {} : { dependencies }), problems: [] };
@@ -11,6 +11,7 @@ export * from "./validate.js";
11
11
  export * from "./tags.js";
12
12
  export * from "./git-state.js";
13
13
  export * from "./index-builder.js";
14
+ export * from "./settle.js";
14
15
  export * from "./plan.js";
15
16
  export * from "./bump.js";
16
17
  export * from "./submit-service.js";
@@ -24,7 +24,8 @@
24
24
  import path from "node:path";
25
25
  import semver from "semver";
26
26
  import { hashDirectory } from "../store/hash.js";
27
- import { parseDependencyRef } from "../ref.js";
27
+ import { isNamedReading, parseRef, splitRecipeKey } from "../../refs/parse.js";
28
+ import { RefSource } from "../../refs/scopes.js";
28
29
  import type { RunOptions } from "../providers/git.js";
29
30
  import { listRecipeTags, tagFor, withTaggedTree, type RecipeTag } from "./tags.js";
30
31
  import { nextVersion, type BumpLevel } from "./bump.js";
@@ -343,13 +344,14 @@ export function siblingKeysOf(validation: RepoValidation, key: string): string[]
343
344
 
344
345
  const keys: string[] = [];
345
346
  for (const written of declared) {
346
- let parsed;
347
+ let readings;
347
348
  try {
348
- parsed = parseDependencyRef(written);
349
+ readings = parseRef(written, RefSource.Manifest);
349
350
  } catch {
350
351
  continue;
351
352
  }
352
- if (parsed.kind !== "sibling") continue;
353
+ const parsed = readings[0]!;
354
+ if (!isNamedReading(parsed) || parsed.location !== undefined) continue;
353
355
 
354
356
  if (parsed.recipe !== undefined) {
355
357
  keys.push(`${parsed.namespace}/${parsed.recipe}`);
@@ -502,8 +504,8 @@ function coveredByScope(recipe: ValidatedRecipe, scope: ReleaseScope): boolean {
502
504
 
503
505
  /** The tag that publishes one version of one recipe key. */
504
506
  function tagOf(key: string, version: string): string {
505
- const slash = key.indexOf("/");
506
- return tagFor(key.slice(0, slash), key.slice(slash + 1), version);
507
+ const { namespace, name } = splitRecipeKey(key);
508
+ return tagFor(namespace, name, version);
507
509
  }
508
510
 
509
511
  /** Renders an absolute path as a repository-relative one, with forward slashes. */