@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
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Settling, at release time, the dependencies that read more than one way.
3
+ *
4
+ * Most dependencies say exactly what they mean: `workflow/alpha`, or
5
+ * `github://owner/repo/workflow/alpha`. Two kinds do not:
6
+ *
7
+ * - a GitLab URL with nested groups, which does not say where the project path
8
+ * ends (`gitlab.com/a/b/c/d` may be project `a/b` with namespace `c` and
9
+ * recipe `d`, or project `a/b/c` with namespace `d`);
10
+ * - a browser URL copied from a host's file view, which names a folder whose
11
+ * recipe only that repository's index can say.
12
+ *
13
+ * `sous repo release` settles each of them once, by fetching the index of every
14
+ * candidate repository through the provider layer and keeping the reading whose
15
+ * index publishes what was named. What it settled on is recorded in the index
16
+ * beside the dependency's key, so a consumer reads the answer and never probes.
17
+ *
18
+ * A network failure fails the release: a release never falls through to the
19
+ * next reading, because the reading it fell through to could be the wrong one.
20
+ * Two readings that both publish what is named are a genuine tie, and an error
21
+ * naming the spellings that read one way (the `/*` form for the namespace).
22
+ */
23
+
24
+ import { parseIndexFile, type IndexFile } from "../formats/index-file.js";
25
+ import { INDEX_FILENAME } from "../formats/common.js";
26
+ import {
27
+ builtInProviders,
28
+ providerById,
29
+ type ProviderOptions,
30
+ type RepoProvider,
31
+ } from "../providers/index.js";
32
+ import {
33
+ formatRef,
34
+ isBrowsedReading,
35
+ isNamedReading,
36
+ parseRef,
37
+ refKey,
38
+ type ParsedRef,
39
+ type RefLocation,
40
+ type RefReading,
41
+ } from "../../refs/parse.js";
42
+ import { settleInIndex } from "../../refs/settle.js";
43
+ import { RefSource } from "../../refs/scopes.js";
44
+ import { describeError, type RepoValidation, type ValidationProblem } from "./validate.js";
45
+
46
+ /** What one dependency settled on. */
47
+ export type SettledDependency = {
48
+ /** The canonical identity of the repository publishing it. */
49
+ identity: string;
50
+ /**
51
+ * The recipe keys it reaches in that repository: one for a recipe, and every
52
+ * recipe the namespace publishes for a namespace.
53
+ */
54
+ keys: string[];
55
+ /** The range the manifest wrote, when it wrote one. */
56
+ range?: string;
57
+ };
58
+
59
+ /** What settling found. */
60
+ export type SettleResult = {
61
+ /** Each settled dependency, keyed by the dependency exactly as written (trimmed). */
62
+ settled: Map<string, SettledDependency>;
63
+ /** Everything that could not be settled; every one of them is an error. */
64
+ problems: ValidationProblem[];
65
+ };
66
+
67
+ /** How settling reaches the network. */
68
+ export type SettleOptions = {
69
+ /** The providers to fetch through. Defaults to the built-ins. */
70
+ providers?: RepoProvider[];
71
+ /** Testing seams handed to every index fetch. */
72
+ providerOptions?: ProviderOptions;
73
+ };
74
+
75
+ /**
76
+ * True when a dependency's readings need an index to settle: it reads more than
77
+ * one way, or it names a browser path.
78
+ *
79
+ * @param readings - Every reading of the dependency.
80
+ */
81
+ export function needsSettling(readings: RefReading[]): boolean {
82
+ return readings.length > 1 || readings.some(isBrowsedReading);
83
+ }
84
+
85
+ /**
86
+ * Settles every dependency in a repository that reads more than one way.
87
+ * Dependencies that read one way are left alone, and nothing is fetched for
88
+ * them, so a repository that uses none of these forms releases offline as
89
+ * before.
90
+ *
91
+ * @param validation - The validated repository.
92
+ * @param options - The providers, and testing seams.
93
+ */
94
+ export async function settleDependencyLocations(
95
+ validation: RepoValidation,
96
+ options: SettleOptions = {}
97
+ ): Promise<SettleResult> {
98
+ const providers = options.providers ?? builtInProviders();
99
+ const settled = new Map<string, SettledDependency>();
100
+ const problems: ValidationProblem[] = [];
101
+ const indexes = new Map<string, Promise<IndexFile>>();
102
+
103
+ const indexOf = (location: RefLocation): Promise<IndexFile> => {
104
+ let pending = indexes.get(location.identity);
105
+ if (pending === undefined) {
106
+ pending = fetchIndexAt(location, providers, options.providerOptions ?? {});
107
+ indexes.set(location.identity, pending);
108
+ }
109
+ return pending;
110
+ };
111
+
112
+ for (const recipe of validation.recipes) {
113
+ const declared = [...(recipe.manifest.depends ?? []), ...(recipe.manifest.subscribes ?? [])];
114
+ for (const written of declared) {
115
+ const trimmed = written.trim();
116
+ if (settled.has(trimmed)) continue;
117
+
118
+ let readings: RefReading[];
119
+ try {
120
+ readings = parseRef(trimmed, RefSource.Manifest, providers);
121
+ } catch {
122
+ // Validation already reported a dependency that does not parse.
123
+ continue;
124
+ }
125
+ if (!needsSettling(readings)) continue;
126
+
127
+ const where = `${recipe.path} ('${trimmed}')`;
128
+ try {
129
+ settled.set(trimmed, await settleOne(trimmed, readings, indexOf, providers));
130
+ } catch (error) {
131
+ problems.push({ level: "error", where, message: describeError(error) });
132
+ }
133
+ }
134
+ }
135
+
136
+ return { settled, problems };
137
+ }
138
+
139
+ /**
140
+ * Settles one dependency: fetches the index behind each reading, and keeps the
141
+ * one reading that publishes what it names.
142
+ *
143
+ * @param written - The dependency as written.
144
+ * @param readings - Every reading of it.
145
+ * @param indexOf - Fetches (once) the index at a location.
146
+ * @param providers - The providers, for formatting.
147
+ */
148
+ async function settleOne(
149
+ written: string,
150
+ readings: RefReading[],
151
+ indexOf: (location: RefLocation) => Promise<IndexFile>,
152
+ providers: RepoProvider[]
153
+ ): Promise<SettledDependency> {
154
+ const found: Array<{ reading: RefReading; settled: ParsedRef[]; index: IndexFile }> = [];
155
+
156
+ for (const reading of readings) {
157
+ const location = reading.location;
158
+ /* c8 ignore next */
159
+ if (location === undefined) continue;
160
+
161
+ let index: IndexFile;
162
+ try {
163
+ index = await indexOf(location);
164
+ } catch (error) {
165
+ throw new Error(
166
+ `the dependency reads ${readings.length === 1 ? "as a folder in" : "as something in"} ` +
167
+ `the repository at ${location.url}, and its index could not be read, so the release ` +
168
+ `cannot settle what it means. A release never guesses past an index it could not ` +
169
+ `read.\n ${describeError(error)}`
170
+ );
171
+ }
172
+
173
+ const settled = settleInIndex(reading, {
174
+ namespaces: Object.keys(index.namespaces),
175
+ recipes: index.recipes,
176
+ });
177
+ if (settled.length > 0) found.push({ reading, settled, index });
178
+ }
179
+
180
+ if (found.length === 0) {
181
+ throw new Error(
182
+ `no repository it could name publishes what it names. It was read as:\n` +
183
+ readings.map((reading) => ` ${formatRef(reading, providers)}`).join("\n")
184
+ );
185
+ }
186
+
187
+ const choices = found.flatMap((entry) => entry.settled);
188
+ if (choices.length > 1) {
189
+ throw new Error(
190
+ `it names more than one thing, and each of these publishes it:\n` +
191
+ choices.map((choice) => ` ${formatRef(choice, providers)}`).join("\n") +
192
+ `\n Write the one you mean. A namespace is written with '/*' after it, as in ` +
193
+ `'${spelledOut(choices.find((choice) => choice.recipe === undefined) ?? choices[0]!, providers)}', ` +
194
+ `and a recipe as its canonical locator above.`
195
+ );
196
+ }
197
+
198
+ const { index } = found[0]!;
199
+ const choice = choices[0]!;
200
+ const keys =
201
+ choice.recipe === undefined
202
+ ? Object.keys(index.recipes)
203
+ .filter((key) => key.startsWith(`${choice.namespace}/`))
204
+ .sort()
205
+ : [refKey(choice)];
206
+ const range = "range" in found[0]!.reading ? found[0]!.reading.range : undefined;
207
+ return {
208
+ identity: choice.location!.identity,
209
+ keys,
210
+ ...(range === undefined ? {} : { range }),
211
+ };
212
+ }
213
+
214
+ /** A reading spelled so it reads one way: a namespace with `/*` after it. */
215
+ function spelledOut(reading: ParsedRef, providers: RepoProvider[]): string {
216
+ const written = formatRef(reading, providers);
217
+ return reading.recipe === undefined && isNamedReading(reading) ? `${written}/*` : written;
218
+ }
219
+
220
+ /**
221
+ * Fetches and validates the index at a location, through its provider.
222
+ *
223
+ * @param location - Where the repository lives.
224
+ * @param providers - The providers to fetch through.
225
+ * @param options - Testing seams.
226
+ */
227
+ async function fetchIndexAt(
228
+ location: RefLocation,
229
+ providers: RepoProvider[],
230
+ options: ProviderOptions
231
+ ): Promise<IndexFile> {
232
+ const provider = providerById(location.provider, providers);
233
+ /* c8 ignore next */
234
+ if (provider === undefined) throw new Error(`sous has no '${location.provider}' provider.`);
235
+ const fetched = await provider.fetchIndex(provider.canonicalize(location.url), options);
236
+ return parseIndexFile(JSON.parse(fetched.text), `${location.url}/${INDEX_FILENAME}`);
237
+ }
@@ -16,6 +16,7 @@ import os from "node:os";
16
16
  import path from "node:path";
17
17
  import { runGit, type RunOptions } from "../providers/git.js";
18
18
  import { RECIPE_KEY_PATTERN } from "../formats/patterns.js";
19
+ import { splitRecipeKey } from "../../refs/parse.js";
19
20
 
20
21
  /** One release tag, taken apart into the recipe it publishes. */
21
22
  export type RecipeTag = {
@@ -60,13 +61,8 @@ export function parseRecipeTag(tag: string): RecipeTag | undefined {
60
61
  const version = tag.slice(at + 1);
61
62
  if (!RECIPE_KEY_PATTERN.test(key)) return undefined;
62
63
 
63
- const slash = key.indexOf("/");
64
- return {
65
- tag,
66
- namespace: key.slice(0, slash),
67
- name: key.slice(slash + 1),
68
- version,
69
- };
64
+ const { namespace, name } = splitRecipeKey(key);
65
+ return { tag, namespace, name, version };
70
66
  }
71
67
 
72
68
  /**
@@ -27,14 +27,17 @@ import type { IndexFile } from "./formats/index-file.js";
27
27
  import type { LockKind } from "./formats/lockfile.js";
28
28
  import type { RecipeManifest } from "./formats/recipe-manifest.js";
29
29
  import {
30
- dependencyRefKey,
31
- dependencyRepoUrl,
32
30
  formatRef,
33
- parseDependencyRef,
31
+ isBrowsedReading,
32
+ isNamedReading,
33
+ parseRef,
34
34
  refKey,
35
- type DependencyRef,
35
+ splitRecipeKey,
36
36
  type ParsedRef,
37
- } from "./ref.js";
37
+ type RefReading,
38
+ } from "../refs/parse.js";
39
+ import { settleBrowsed, settleDependency, settleInIndex } from "../refs/settle.js";
40
+ import { RefSource } from "../refs/scopes.js";
38
41
  import { shortNameFromIdentity } from "./identity.js";
39
42
  import type { IndexDependency } from "./formats/index-file.js";
40
43
 
@@ -186,6 +189,15 @@ type WorkItem = RefRequest & {
186
189
  provider?: string;
187
190
  /** The dependency exactly as the manifest wrote it, for messages. */
188
191
  written: string;
192
+ /**
193
+ * A browser path the dependency named, settled through the repository's
194
+ * index once the project has it. Until then `ref` holds no namespace.
195
+ */
196
+ browsed?: string;
197
+ /** The range the manifest wrote, applied once a browsed path is settled. */
198
+ range?: string;
199
+ /** What the declaring version's index records, for pins once a browsed path is settled. */
200
+ pins?: Record<string, IndexDependency>;
189
201
  };
190
202
  };
191
203
 
@@ -200,24 +212,27 @@ type WorkItem = RefRequest & {
200
212
  * version is what gets asked for, rather than whatever the declared range would
201
213
  * reach today.
202
214
  *
215
+ * A dependency that reads more than one way (a GitLab URL with nested groups)
216
+ * is settled by what the release recorded in the index, and failing that by
217
+ * what the project's own cached indexes already publish; nothing is fetched to
218
+ * find out. One that still reads several ways is an error naming them.
219
+ *
203
220
  * @param written - The dependency as the manifest wrote it.
204
221
  * @param parent - The recipe whose manifest declared it.
205
222
  * @param kind - Whether it was declared as a dependency or a co-subscription.
223
+ * @param context - The added repositories and their cached indexes.
206
224
  */
207
225
  function dependencyRequest(
208
226
  written: string,
209
227
  parent: ResolvedRecipe,
210
- kind: LockKind
228
+ kind: LockKind,
229
+ context: ResolveContext
211
230
  ): 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
- }
231
+ const reading = settleDependency(written, {
232
+ ...(parent.dependencies === undefined ? {} : { recorded: parent.dependencies }),
233
+ known: (candidate) => knownLocally(candidate, context),
234
+ });
235
+ if (reading === undefined) throw unsettledDependencyError(written, parent);
221
236
 
222
237
  const base = {
223
238
  requestedBy: parent.key,
@@ -225,22 +240,143 @@ function dependencyRequest(
225
240
  ...(parent.prerelease ? { prerelease: true } : {}),
226
241
  };
227
242
 
228
- if (parsed.kind === "sibling") {
243
+ if (isBrowsedReading(reading)) {
244
+ return {
245
+ ref: { namespace: "" },
246
+ ...base,
247
+ remote: {
248
+ ...remoteOf(reading, written),
249
+ browsed: reading.browsed,
250
+ ...(reading.range === undefined ? {} : { range: reading.range }),
251
+ ...(parent.dependencies === undefined ? {} : { pins: parent.dependencies }),
252
+ },
253
+ };
254
+ }
255
+ /* c8 ignore next */
256
+ if (!isNamedReading(reading)) throw unsettledDependencyError(written, parent);
257
+
258
+ const pinned = parent.dependencies?.[refKey(reading)];
259
+ const ref = pinnedRef(reading, pinned);
260
+
261
+ if (reading.location === undefined) {
229
262
  return { ref: { ...ref, repo: parent.repo }, ...base };
230
263
  }
231
264
 
265
+ const remote = remoteOf(reading, written);
232
266
  return {
233
267
  ref,
234
268
  ...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
- },
269
+ remote: { ...remote, identity: pinned?.repo ?? remote.identity },
241
270
  };
242
271
  }
243
272
 
273
+ /**
274
+ * A named reading as a request asks for it: at the exact version the index
275
+ * recorded when it recorded one, otherwise at the range the manifest wrote.
276
+ *
277
+ * @param reading - The reading.
278
+ * @param pinned - What the declaring version's index recorded for it.
279
+ */
280
+ function pinnedRef(reading: ParsedRef, pinned: IndexDependency | undefined): ParsedRef {
281
+ const ref: ParsedRef = { namespace: reading.namespace };
282
+ if (reading.recipe !== undefined) {
283
+ ref.recipe = reading.recipe;
284
+ const range = pinned?.version ?? reading.range ?? pinned?.range;
285
+ if (range !== undefined) ref.range = range;
286
+ }
287
+ return ref;
288
+ }
289
+
290
+ /** Where a located reading's repository lives, as a request carries it. */
291
+ function remoteOf(
292
+ reading: RefReading,
293
+ written: string
294
+ ): NonNullable<WorkItem["remote"]> {
295
+ const location = reading.location!;
296
+ return {
297
+ identity: location.identity,
298
+ url: location.url,
299
+ provider: location.provider,
300
+ written: written.trim(),
301
+ };
302
+ }
303
+
304
+ /**
305
+ * Whether a reading's repository is one the project added and its cached index
306
+ * publishes what the reading names. It reads only what is already here.
307
+ *
308
+ * @param reading - One reading of a dependency.
309
+ * @param context - The added repositories and their cached indexes.
310
+ */
311
+ function knownLocally(reading: RefReading, context: ResolveContext): boolean {
312
+ if (reading.location === undefined) return false;
313
+ const added = repoNamedByIdentity(context.repos, reading.location.identity);
314
+ const index = added === undefined ? undefined : context.indexes.get(added);
315
+ if (index === undefined) return false;
316
+ return settleInIndex(reading, { namespaces: Object.keys(index.namespaces), recipes: index.recipes }).length > 0;
317
+ }
318
+
319
+ /**
320
+ * The error for a dependency that reads more than one way with nothing to
321
+ * settle it.
322
+ *
323
+ * @param written - The dependency as written.
324
+ * @param parent - The recipe that declared it.
325
+ */
326
+ function unsettledDependencyError(written: string, parent: ResolvedRecipe): ConfigError {
327
+ const readings = parseRef(written, RefSource.Manifest);
328
+ return new ConfigError(
329
+ `The dependency '${written.trim()}' of the recipe '${parent.key}' can be read ` +
330
+ `${readings.length} ways, and nothing says which one it means:\n` +
331
+ readings.map((reading) => ` ${formatRef(reading)}`).join("\n") +
332
+ `\n A release records which one it settled on in the index; the index of ` +
333
+ `'${parent.repo}' records none for this version. Release the recipe again with this ` +
334
+ `version of sous, or write the dependency so it reads one way, as one of the forms above.`
335
+ );
336
+ }
337
+
338
+ /**
339
+ * Settles a browsed path, once the project has the repository it names.
340
+ *
341
+ * @param item - The request, whose `ref` is filled in.
342
+ * @param index - The repository's cached index.
343
+ */
344
+ function settleBrowsedItem(item: WorkItem, index: IndexFile | undefined): void {
345
+ const remote = item.remote!;
346
+ const settled =
347
+ index === undefined
348
+ ? []
349
+ : settleBrowsed(remote.browsed!, {
350
+ namespaces: Object.keys(index.namespaces),
351
+ recipes: index.recipes,
352
+ });
353
+
354
+ let found: ParsedRef | undefined = settled.length === 1 ? settled[0] : undefined;
355
+ if (found === undefined) {
356
+ // The folder may have moved since the release; the key it settled on then
357
+ // is recorded beside the repository it lives in.
358
+ const recorded = Object.keys(remote.pins ?? {}).filter(
359
+ (key) => remote.pins![key]!.repo === remote.identity
360
+ );
361
+ if (recorded.length === 1) {
362
+ const { namespace, name } = splitRecipeKey(recorded[0]!);
363
+ found = { namespace, recipe: name };
364
+ }
365
+ }
366
+
367
+ if (found === undefined) {
368
+ throw new ConfigError(
369
+ `The dependency '${remote.written}' names a folder, and the index of the repository at ` +
370
+ `${remote.url} ${settled.length === 0 ? "publishes no recipe there" : "publishes several recipes there"}.` +
371
+ `\n It was required by the recipe '${item.requestedBy}'. Write the dependency as the ` +
372
+ `recipe it means, such as '${remote.url}/<namespace>/<recipe>'.`
373
+ );
374
+ }
375
+
376
+ const withRange = { ...found, ...(remote.range === undefined ? {} : { range: remote.range }) };
377
+ item.ref = { ...pinnedRef(withRange, remote.pins?.[refKey(found)]), repo: item.ref.repo };
378
+ }
379
+
244
380
  /**
245
381
  * The short name of the added repository with this identity, or undefined when
246
382
  * the project has added none. Short names are a project's own labels, so the
@@ -249,7 +385,7 @@ function dependencyRequest(
249
385
  * @param repos - The repositories the project has added.
250
386
  * @param identity - The canonical identity to look for.
251
387
  */
252
- function repoNamedByIdentity(
388
+ export function repoNamedByIdentity(
253
389
  repos: Record<string, ResolverRepo>,
254
390
  identity: string
255
391
  ): string | undefined {
@@ -266,7 +402,7 @@ function repoNamedByIdentity(
266
402
  * @param repos - The repositories the project has added.
267
403
  * @param identity - The canonical identity of the repository being named.
268
404
  */
269
- function proposeRepoName(
405
+ export function proposeRepoName(
270
406
  repos: Record<string, ResolverRepo>,
271
407
  identity: string
272
408
  ): string {
@@ -334,6 +470,7 @@ export async function resolveRefs(
334
470
  continue;
335
471
  }
336
472
  item.ref = { ...item.ref, repo: added };
473
+ if (item.remote.browsed !== undefined) settleBrowsedItem(item, context.indexes.get(added));
337
474
  }
338
475
 
339
476
  if (item.ref.recipe === undefined) {
@@ -369,7 +506,7 @@ export async function resolveRefs(
369
506
  ["subscribes", manifest.subscribes ?? []],
370
507
  ] as Array<[LockKind, string[]]>) {
371
508
  for (const written of refs) {
372
- queue.push(dependencyRequest(written, recipe, kind));
509
+ queue.push(dependencyRequest(written, recipe, kind, context));
373
510
  }
374
511
  }
375
512
  }
@@ -473,20 +610,7 @@ async function keepOnlyReachable(
473
610
  ["subscribes", manifest.subscribes ?? []],
474
611
  ] as Array<[LockKind, string[]]>) {
475
612
  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) {
613
+ for (const target of dependencyTargets(written, recipe, context, resolved)) {
490
614
  if (!resolved.has(target)) continue;
491
615
  if (declare(key, target, kind)) queue.push(target);
492
616
  }
@@ -506,6 +630,39 @@ async function keepOnlyReachable(
506
630
  }
507
631
  }
508
632
 
633
+ /**
634
+ * The resolved keys one dependency of a recipe reaches, the same way the walk
635
+ * reached them: a namespace means every recipe in it, and a browsed path means
636
+ * the recipe it settles on. A dependency that no longer settles reaches
637
+ * nothing, because the walk would have failed on it.
638
+ *
639
+ * @param written - The dependency as the manifest wrote it.
640
+ * @param holder - The recipe that declared it.
641
+ * @param context - The added repositories and their cached indexes.
642
+ * @param resolved - What the walk resolved.
643
+ */
644
+ function dependencyTargets(
645
+ written: string,
646
+ holder: ResolvedRecipe,
647
+ context: ResolveContext,
648
+ resolved: Map<string, ResolvedRecipe>
649
+ ): string[] {
650
+ let item: WorkItem;
651
+ try {
652
+ item = dependencyRequest(written, holder, "depends", context);
653
+ if (item.remote?.browsed !== undefined) {
654
+ const added = repoNamedByIdentity(context.repos, item.remote.identity);
655
+ settleBrowsedItem(item, added === undefined ? undefined : context.indexes.get(added));
656
+ }
657
+ } catch {
658
+ return [];
659
+ }
660
+ const { namespace } = item.ref;
661
+ return item.ref.recipe === undefined
662
+ ? [...resolved.keys()].filter((entry) => entry.startsWith(`${namespace}/`))
663
+ : [refKey(item.ref)];
664
+ }
665
+
509
666
  /** Records one more reason a repository is needed. */
510
667
  function recordMissingRepo(into: Map<string, MissingRepo>, missing: MissingRepo): void {
511
668
  const existing = into.get(missing.name);
@@ -622,11 +779,11 @@ function resolveRecipeRef(
622
779
  const requestedBy = [...(previous?.requestedBy ?? [])];
623
780
  if (!requestedBy.includes(item.requestedBy)) requestedBy.push(item.requestedBy);
624
781
 
625
- const namespace = key.slice(0, key.indexOf("/"));
782
+ const { namespace, name } = splitRecipeKey(key);
626
783
  return {
627
784
  key,
628
785
  namespace,
629
- name: key.slice(namespace.length + 1),
786
+ name,
630
787
  repo: chosen.repo,
631
788
  identity: context.repos[chosen.repo]?.identity ?? chosen.repo,
632
789
  version,