@sous-io/sous 0.2.28 → 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
@@ -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
+ }
@@ -29,7 +29,15 @@ import type {
29
29
  RecipeManifest,
30
30
  VariableDefinition,
31
31
  } from "./formats/recipe-manifest.js";
32
- import { dependencyRefKey, parseDependencyRef, parseRef, type ParsedRef } from "./ref.js";
32
+ import {
33
+ isNamedReading,
34
+ namespaceOfKey,
35
+ parseRef,
36
+ refKey,
37
+ splitRecipeKey,
38
+ type ParsedRef,
39
+ } from "../refs/parse.js";
40
+ import { settleDependency } from "../refs/settle.js";
33
41
  import { bareName } from "../vars/names.js";
34
42
  import {
35
43
  describeReference,
@@ -390,7 +398,7 @@ export function narrowToInstalled(inputs: CatalogInputs): CatalogInputs {
390
398
  ([key]) => inputs.lock.recipes[key]?.repo === repo.name
391
399
  )
392
400
  );
393
- const held = new Set(Object.keys(recipes).map((key) => key.slice(0, key.indexOf("/"))));
401
+ const held = new Set(Object.keys(recipes).map(namespaceOfKey));
394
402
  const namespaces = Object.fromEntries(
395
403
  Object.entries(repo.index.namespaces).filter(([namespace]) => held.has(namespace))
396
404
  );
@@ -468,16 +476,16 @@ export type ResolvedRecipeRef = {
468
476
  * @param ref - The namespace, optionally qualified with `repo:`.
469
477
  */
470
478
  export function resolveNamespaceRef(inputs: CatalogInputs, ref: string): ResolvedNamespace {
471
- const parsed = parseRef(ref);
479
+ const { parsed, search } = readCommandLineRef(ref);
472
480
 
473
- if (parsed.recipe !== undefined) {
481
+ if (parsed?.recipe !== undefined) {
474
482
  throw new ConfigError(
475
483
  `'${ref}' names the recipe '${parsed.recipe}', not a namespace.\n` +
476
484
  ` The namespace it belongs to is '${parsed.namespace}'.`
477
485
  );
478
486
  }
479
487
 
480
- const matches = findNamespace(refSearchString(parsed), referenceContext(inputs));
488
+ const matches = findNamespace(search, referenceContext(inputs));
481
489
 
482
490
  if (matches.length === 1) {
483
491
  return { repo: repoNamed(inputs, matches[0]!.repo!), namespace: matches[0]!.namespace! };
@@ -485,8 +493,8 @@ export function resolveNamespaceRef(inputs: CatalogInputs, ref: string): Resolve
485
493
 
486
494
  if (matches.length > 1) throw ambiguousError(ref, "namespace", matches);
487
495
 
488
- const asRecipe = findRecipe(refSearchString(parsed), referenceContext(inputs));
489
- if (parsed.repo === undefined && asRecipe.length > 0) {
496
+ const asRecipe = findRecipe(search, referenceContext(inputs));
497
+ if (parsed !== undefined && parsed.repo === undefined && asRecipe.length > 0) {
490
498
  throw new ConfigError(
491
499
  `No repository this project trusts publishes a namespace called ` +
492
500
  `'${parsed.namespace}'.\n` +
@@ -495,7 +503,7 @@ export function resolveNamespaceRef(inputs: CatalogInputs, ref: string): Resolve
495
503
  );
496
504
  }
497
505
 
498
- throw unknownError(inputs, ref, "namespace", parsed.repo);
506
+ throw unknownError(inputs, ref, "namespace", parsed?.repo);
499
507
  }
500
508
 
501
509
  /**
@@ -507,8 +515,7 @@ export function resolveNamespaceRef(inputs: CatalogInputs, ref: string): Resolve
507
515
  * @param ref - The recipe, as `namespace/recipe`, a bare recipe name, or either qualified with `repo:`.
508
516
  */
509
517
  export function resolveRecipeRef(inputs: CatalogInputs, ref: string): ResolvedRecipeRef {
510
- const parsed = parseRef(ref);
511
- const search = refSearchString(parsed);
518
+ const { parsed, search } = readCommandLineRef(ref);
512
519
  const context = referenceContext(inputs);
513
520
 
514
521
  const matches = findRecipe(search, context);
@@ -529,13 +536,13 @@ export function resolveRecipeRef(inputs: CatalogInputs, ref: string): ResolvedRe
529
536
  if (asNamespace.length > 0) {
530
537
  throw new ConfigError(
531
538
  `No repository this project trusts publishes a recipe called ` +
532
- `'${parsed.namespace}'.\n` +
539
+ `'${parsed?.namespace ?? ref}'.\n` +
533
540
  ` It is the name of a namespace:\n` +
534
541
  asNamespace.map((candidate) => ` ${describeReference(candidate)}`).join("\n")
535
542
  );
536
543
  }
537
544
 
538
- throw unknownError(inputs, ref, "recipe", parsed.repo);
545
+ throw unknownError(inputs, ref, "recipe", parsed?.repo);
539
546
  }
540
547
 
541
548
  // --- The pieces ---------------------------------------------------------------------------------
@@ -543,7 +550,7 @@ export function resolveRecipeRef(inputs: CatalogInputs, ref: string): ResolvedRe
543
550
  /** Every recipe key one index publishes in one namespace, sorted. */
544
551
  function recipeKeysIn(index: IndexFile, namespace: string): string[] {
545
552
  return Object.keys(index.recipes)
546
- .filter((key) => key.slice(0, key.indexOf("/")) === namespace)
553
+ .filter((key) => namespaceOfKey(key) === namespace)
547
554
  .sort();
548
555
  }
549
556
 
@@ -583,8 +590,7 @@ function recipeListing(
583
590
  ): RecipeListing {
584
591
  const lock = inputs.lock;
585
592
  const entry = repo.index.recipes[key]!;
586
- const namespace = key.slice(0, key.indexOf("/"));
587
- const name = key.slice(namespace.length + 1);
593
+ const { namespace, name } = splitRecipeKey(key);
588
594
  const latest = latestVersion(Object.keys(entry.versions), entry.versions);
589
595
 
590
596
  // A locked entry pins one recipe from one repository. Two repositories can
@@ -689,12 +695,17 @@ function dependencyListings(
689
695
  ["subscribes", manifest?.subscribes ?? []],
690
696
  ] as Array<["depends" | "subscribes", string[]]>) {
691
697
  for (const written of declared) {
692
- let key: string;
698
+ // A dependency that reads more than one way is listed under the key the
699
+ // index recorded for it. One sous cannot parse or settle is still worth
700
+ // showing; it is listed under what it was written as, so the reader sees
701
+ // the entry as it stands.
702
+ let key = written;
693
703
  try {
694
- key = dependencyRefKey(parseDependencyRef(written));
704
+ const reading = settleDependency(written, {
705
+ ...(resolved === undefined ? {} : { recorded: resolved }),
706
+ });
707
+ if (reading !== undefined && isNamedReading(reading)) key = refKey(reading);
695
708
  } 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
709
  key = written;
699
710
  }
700
711
  rows.set(key, { ...(rows.get(key) ?? { key }), declared: written, kind });
@@ -746,14 +757,22 @@ function referenceContext(inputs: CatalogInputs): ReferenceContext {
746
757
  }
747
758
 
748
759
  /**
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.
760
+ * A ref from the command line, checked by the one parser, and the spelling to
761
+ * search for: a short ref written back without its version range (the range
762
+ * says which version to use, never which thing is meant), or a location
763
+ * exactly as written, which the search settles through the repository's index.
751
764
  *
752
- * @param parsed - The parsed ref.
765
+ * @param ref - The ref as typed.
766
+ * @returns The short reading, when the ref was short, and the search term.
753
767
  */
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}`;
768
+ function readCommandLineRef(ref: string): { parsed?: ParsedRef; search: string } {
769
+ const readings = parseRef(ref);
770
+ const first = readings[0]!;
771
+ if (first.location !== undefined || !isNamedReading(first)) return { search: ref.trim() };
772
+ return {
773
+ parsed: first,
774
+ search: first.repo === undefined ? refKey(first) : `${first.repo}:${refKey(first)}`,
775
+ };
757
776
  }
758
777
 
759
778
  /** The error a ref that could have meant several things raises. */
@@ -31,6 +31,7 @@ import {
31
31
  semverVersionSchema,
32
32
  stableJsonStringify,
33
33
  } from "./common.js";
34
+ import { namespaceOfKey } from "../../refs/parse.js";
34
35
 
35
36
  /**
36
37
  * One dependency of one published version, as the release resolved it.
@@ -142,7 +143,7 @@ export const indexFileSchema = forwardCompatibleObject({
142
143
  // A recipe whose namespace is not declared could never be resolved, so a
143
144
  // release that produced one is broken; say which recipe and which namespace.
144
145
  for (const key of Object.keys(index.recipes)) {
145
- const namespace = key.slice(0, key.indexOf("/"));
146
+ const namespace = namespaceOfKey(key);
146
147
  if (!Object.hasOwn(index.namespaces, namespace)) {
147
148
  ctx.addIssue({
148
149
  code: "custom",
@@ -15,13 +15,15 @@
15
15
  * its files DO enter the output. A curated bundle is simply a recipe made
16
16
  * mostly of `subscribes` entries.
17
17
  *
18
- * Both lists name their targets BY LOCATION, in one of two spellings:
18
+ * Both lists name their targets BY LOCATION:
19
19
  *
20
20
  * workflow/sat a sibling in this repository
21
21
  * github://sous-io/sous-recipes/workflow/sat a recipe in another one
22
22
  *
23
- * A short name such as `sous-recipes:` is a consuming project's own label, so
24
- * it never appears in a published manifest; see `parseDependencyRef`.
23
+ * Every other location form works too (an HTTPS or SSH URL, a browser URL); a
24
+ * short name such as `sous-recipes:` is a consuming project's own label, so it
25
+ * never appears in a published manifest. See `parseRef` with
26
+ * `RefSource.Manifest`.
25
27
  */
26
28
 
27
29
  import { z } from "zod";
@@ -37,20 +39,20 @@ import {
37
39
  submissionsSchema,
38
40
  variableNameSchema,
39
41
  } from "./common.js";
40
- import { parseDependencyRef } from "../ref.js";
42
+ import { parseRef } from "../../refs/parse.js";
43
+ import { RefSource } from "../../refs/scopes.js";
41
44
 
42
45
  // --- Dependency refs ----------------------------------------------------------------------------
43
46
 
44
47
  /**
45
- * A dependency in `depends` or `subscribes`, in either of the two spellings a
46
- * manifest may use: a bare ref naming a recipe in this same repository, or a
47
- * locator URL naming one in another repository. Parsed with the real parser so
48
+ * A dependency in `depends` or `subscribes`: a bare ref naming a recipe in this
49
+ * same repository, or a location naming one in another repository. Parsed with the real parser so
48
50
  * a manifest and the rest of sous never disagree about what a dependency means;
49
51
  * the parser's message is carried through as the zod issue message.
50
52
  */
51
53
  const dependencyRefSchema = z.string().superRefine((value, ctx) => {
52
54
  try {
53
- parseDependencyRef(value);
55
+ parseRef(value, RefSource.Manifest);
54
56
  } catch (error) {
55
57
  ctx.addIssue({ code: "custom", message: (error as Error).message });
56
58
  }
@@ -18,7 +18,6 @@ export * from "./formats/lockfile.js";
18
18
  export * from "./formats/store-entry.js";
19
19
  export * from "./formats/links-map.js";
20
20
  export * from "./load-manifest.js";
21
- export * from "./ref.js";
22
21
  export * from "./identity.js";
23
22
 
24
23
  // Phase 2a: the machine-wide recipe store, under the user-level sous directory.
@@ -34,6 +34,7 @@ import type { ResolvedRecipe } from "./resolver.js";
34
34
  import type { RecipeStoreLike, StoreKey } from "./store/contract.js";
35
35
  import { builtInProviders, requireProvider } from "./providers/index.js";
36
36
  import { repoIdentity } from "./identity.js";
37
+ import { splitRecipeKey } from "../refs/parse.js";
37
38
  import type { ProviderOptions, RepoProvider } from "./providers/provider.js";
38
39
 
39
40
  /** What the lockfile needs to know about a repository. */
@@ -382,11 +383,11 @@ export class LockService {
382
383
  const report: RestoreReport = { restored: [], alreadyPresent: [] };
383
384
 
384
385
  for (const [key, entry] of Object.entries(lock.recipes)) {
385
- const namespace = key.slice(0, key.indexOf("/"));
386
+ const { namespace, name } = splitRecipeKey(key);
386
387
  const storeKey: StoreKey = {
387
388
  identity: lock.repos[entry.repo]!.identity,
388
389
  namespace,
389
- name: key.slice(namespace.length + 1),
390
+ name,
390
391
  version: entry.version,
391
392
  };
392
393
 
@@ -4,14 +4,17 @@
4
4
  * Phase 4 defined the `NamespaceResolver` contract and shipped an in-memory
5
5
  * implementation for tests. This is the implementation backed by the project's
6
6
  * actual state: the lockfile says which recipes are in play and at what version,
7
- * the links map and the store say where each one's files are, and each recipe's
8
- * own manifest says what it is allowed to address.
7
+ * the links map and the store say where each one's files are, and the
8
+ * lockfile's holder lists say what each recipe is allowed to address.
9
9
  *
10
10
  * The scoping rule is the whole point, and it is enforced by handing
11
11
  * `StaticNamespaceResolver` the right two maps:
12
12
  *
13
13
  * - a file inside a recipe may address only that recipe's own declared
14
- * dependencies (`depends` plus `subscribes`), at their pinned versions;
14
+ * dependencies (`depends` plus `subscribes`), at their pinned versions. The
15
+ * lockfile records which keys each recipe holds, however its manifest wrote
16
+ * them (a sibling ref, a locator, a URL copied from the browser), so that
17
+ * record is what decides it;
15
18
  * - a file in the project's own templates may address every recipe the
16
19
  * lockfile pins, whatever holds it: the project, a set it subscribes to,
17
20
  * or another recipe's `depends`.
@@ -28,16 +31,16 @@
28
31
  import semver from "semver";
29
32
  import type { Settings } from "../settings.js";
30
33
  import { resolveStoreRoot } from "../sous-home.js";
34
+ import { RefSource } from "../refs/scopes.js";
35
+ import { namespaceOfKey, parseShortRef, refKey } from "../refs/parse.js";
31
36
  import {
32
37
  StaticNamespaceResolver,
33
- normalizeRef,
34
38
  type DroppedRecipe,
35
39
  type NamespaceResolver,
36
40
  } from "./namespace-resolver.js";
37
41
  import {
38
42
  listLockedRecipes,
39
43
  readProjectLockfile,
40
- readRecipeManifestIn,
41
44
  type LockedRecipeLocation,
42
45
  } from "./locked-recipes.js";
43
46
  import { createIndexCache } from "./providers/index.js";
@@ -84,14 +87,11 @@ export function createProjectNamespaceResolver(
84
87
  for (const recipe of locked) {
85
88
  recipes[recipe.key] = recipe.dir;
86
89
 
87
- // A recipe declares what it may address in its own manifest. A recipe whose
88
- // files are not on disk yet declares nothing, which is the conservative
89
- // answer: it cannot be including anything either.
90
- const manifest = recipe.present ? readRecipeManifestIn(recipe.dir) : undefined;
91
- if (manifest === undefined) continue;
92
-
93
- const declared = [...(manifest.depends ?? []), ...(manifest.subscribes ?? [])];
94
- if (declared.length > 0) dependencies[recipe.key] = declared;
90
+ // Every recipe holding this one declared it, in `depends` or `subscribes`,
91
+ // and the lockfile records the key that declaration resolved to.
92
+ for (const holder of recipe.requestedBy) {
93
+ (dependencies[holder] ??= []).push(recipe.key);
94
+ }
95
95
  }
96
96
 
97
97
  // No `projectScope`: a project template may address every recipe the
@@ -126,10 +126,16 @@ export function explainUnpinnedRecipe(
126
126
  recipe: string,
127
127
  options: Pick<ProjectNamespaceResolverOptions, "sousDir" | "settings" | "env">
128
128
  ): DroppedRecipe | undefined {
129
- const namespace = recipe.slice(0, recipe.indexOf("/"));
129
+ const namespace = namespaceOfKey(recipe);
130
130
  for (const [key, entry] of Object.entries(options.settings.subscriptions ?? {})) {
131
131
  if (entry?.enabled !== false) continue;
132
- const ref = normalizeRef(key);
132
+ let ref: string;
133
+ try {
134
+ ref = refKey(parseShortRef(key, RefSource.Config));
135
+ } catch {
136
+ // The config schema already refuses a key that is not a ref.
137
+ continue;
138
+ }
133
139
  if (ref === recipe || ref === namespace) {
134
140
  return { by: "disabled-subscription", subscription: key };
135
141
  }
@@ -33,6 +33,7 @@ import {
33
33
  } from "./load-manifest.js";
34
34
  import { readEffectiveLinks } from "./links.js";
35
35
  import { identitySegments } from "./identity.js";
36
+ import { splitRecipeKey } from "../refs/parse.js";
36
37
  import { PROJECT_HOLDER } from "./formats/lockfile.js";
37
38
 
38
39
  /** One locked recipe, together with the directory its files are read from. */
@@ -164,8 +165,7 @@ export function listLockedRecipes(
164
165
 
165
166
  for (const key of keys.sort()) {
166
167
  const entry = lock.recipes[key]!;
167
- const namespace = key.slice(0, key.indexOf("/"));
168
- const name = key.slice(namespace.length + 1);
168
+ const { namespace, name } = splitRecipeKey(key);
169
169
 
170
170
  let dir: string | undefined;
171
171
  let linked = false;