@sous-io/sous 0.2.32 → 0.2.33

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 (103) hide show
  1. package/README.md +7 -6
  2. package/docs/markdown/_sidebar.md +1 -0
  3. package/docs/markdown/commands.md +8 -4
  4. package/docs/markdown/configuration.md +28 -4
  5. package/docs/markdown/repositories-authoring.md +22 -2
  6. package/docs/markdown/repositories-consuming.md +40 -11
  7. package/docs/markdown/repositories-file-formats.md +28 -13
  8. package/docs/markdown/repositories-quickstart.md +1 -1
  9. package/docs/markdown/repositories.md +8 -4
  10. package/package.json +2 -1
  11. package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +25 -17
  12. package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +4 -0
  13. package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +21 -9
  14. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  15. package/sous.config.schema.json +27 -15
  16. package/src/commands/lock/rebuild.ts +3 -6
  17. package/src/commands/namespace/show.ts +2 -2
  18. package/src/commands/recipe/show.ts +26 -9
  19. package/src/commands/repo/contribute.ts +32 -23
  20. package/src/commands/repo/gc.ts +1 -1
  21. package/src/commands/repo/remove.ts +1 -1
  22. package/src/commands/repo/search.ts +1 -1
  23. package/src/commands/vars/ask.ts +75 -54
  24. package/src/lib/build-service.ts +85 -11
  25. package/src/lib/config-schema.ts +59 -19
  26. package/src/lib/include-resolver.ts +307 -69
  27. package/src/lib/markdown-compiler.ts +273 -81
  28. package/src/lib/project-scaffold/templates.ts +19 -11
  29. package/src/lib/repos/catalog-inputs.ts +1 -1
  30. package/src/lib/repos/catalog.ts +98 -72
  31. package/src/lib/repos/declarations.ts +22 -12
  32. package/src/lib/repos/formats/index-file.ts +1 -1
  33. package/src/lib/repos/formats/recipe-manifest.ts +3 -4
  34. package/src/lib/repos/lock-service.ts +1 -1
  35. package/src/lib/repos/locked-namespace-resolver.ts +14 -6
  36. package/src/lib/repos/locked-recipe-lookup.ts +123 -0
  37. package/src/lib/repos/locked-recipes.ts +1 -1
  38. package/src/lib/repos/memories-hash-name.ts +33 -0
  39. package/src/lib/repos/namespace-resolver.ts +198 -119
  40. package/src/lib/repos/recipe-config-layers.ts +29 -1
  41. package/src/lib/repos/recipe-key-matcher.ts +62 -0
  42. package/src/lib/repos/recipe-memories.ts +175 -0
  43. package/src/lib/repos/recipe-targets.ts +27 -28
  44. package/src/lib/repos/ref-search.ts +1 -1
  45. package/src/lib/repos/release/index-builder.ts +23 -15
  46. package/src/lib/repos/release/plan.ts +14 -7
  47. package/src/lib/repos/release/settle.ts +38 -121
  48. package/src/lib/repos/release/submit-checkout.ts +42 -23
  49. package/src/lib/repos/release/tags.ts +1 -1
  50. package/src/lib/repos/resolver.ts +90 -72
  51. package/src/lib/repos/subscription-service.ts +192 -170
  52. package/src/lib/settings.ts +37 -33
  53. package/src/lib/vars/ask.ts +3 -3
  54. package/src/lib/vars/mappings.ts +17 -9
  55. package/src/services/ref-resolver/container.ts +107 -0
  56. package/src/services/ref-resolver/dependency.ts +60 -0
  57. package/src/services/ref-resolver/format.ts +197 -0
  58. package/src/services/ref-resolver/glob.ts +115 -0
  59. package/src/services/ref-resolver/hash-names.ts +155 -0
  60. package/src/services/ref-resolver/index.ts +55 -0
  61. package/src/services/ref-resolver/injectable.ts +36 -0
  62. package/src/services/ref-resolver/location.ts +62 -0
  63. package/src/services/ref-resolver/lookups/cached-index-lookup.ts +53 -0
  64. package/src/services/ref-resolver/lookups/catalog-lookup.ts +23 -0
  65. package/src/services/ref-resolver/lookups/catalog-matcher.ts +261 -0
  66. package/src/services/ref-resolver/lookups/chained-lookup.ts +27 -0
  67. package/src/services/ref-resolver/lookups/env-var-lookup.ts +65 -0
  68. package/src/services/ref-resolver/lookups/fetched-index-lookup.ts +150 -0
  69. package/src/services/ref-resolver/lookups/recorded-dependency-lookup.ts +62 -0
  70. package/src/services/ref-resolver/lookups/ref-lookup.ts +54 -0
  71. package/src/services/ref-resolver/lookups/variable-lookup.ts +123 -0
  72. package/src/services/ref-resolver/named-refs.ts +144 -0
  73. package/src/services/ref-resolver/parser/env-var-splitter.ts +27 -0
  74. package/src/services/ref-resolver/parser/location-splitter.ts +239 -0
  75. package/src/services/ref-resolver/parser/name-path-splitter.ts +200 -0
  76. package/src/services/ref-resolver/parser/partial-ref.ts +84 -0
  77. package/src/services/ref-resolver/parser/query-splitter.ts +61 -0
  78. package/src/services/ref-resolver/parser/range-splitter.ts +52 -0
  79. package/src/services/ref-resolver/parser/ref-parser.ts +115 -0
  80. package/src/services/ref-resolver/parser/repo-qualifier-splitter.ts +51 -0
  81. package/src/services/ref-resolver/parts.ts +115 -0
  82. package/src/services/ref-resolver/pruners/command-line-pruner.ts +47 -0
  83. package/src/services/ref-resolver/pruners/include-pruner.ts +66 -0
  84. package/src/services/ref-resolver/pruners/manifest-pruner.ts +130 -0
  85. package/src/services/ref-resolver/pruners/ref-pruner.ts +135 -0
  86. package/src/services/ref-resolver/pruners/rule-helpers.ts +44 -0
  87. package/src/services/ref-resolver/pruners/stored-key-pruner.ts +136 -0
  88. package/src/services/ref-resolver/ref-helpers.ts +62 -0
  89. package/src/services/ref-resolver/ref-picker-service.ts +204 -0
  90. package/src/{lib/repos/reference-report.ts → services/ref-resolver/ref-report.ts} +2 -40
  91. package/src/services/ref-resolver/ref-resolve-arguments.ts +47 -0
  92. package/src/services/ref-resolver/ref-resolve-result.ts +36 -0
  93. package/src/services/ref-resolver/ref-resolver-service.ts +323 -0
  94. package/src/services/ref-resolver/source.ts +48 -0
  95. package/src/services/ref-resolver/tokens.ts +25 -0
  96. package/src/services/ref-resolver/types.ts +155 -0
  97. package/src/templating/init-liquid-engine.ts +25 -18
  98. package/src/lib/refs/find.ts +0 -770
  99. package/src/lib/refs/index.ts +0 -14
  100. package/src/lib/refs/parse.ts +0 -844
  101. package/src/lib/refs/pick.ts +0 -147
  102. package/src/lib/refs/scopes.ts +0 -95
  103. package/src/lib/refs/settle.ts +0 -219
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The memories the project's active recipes publish, listed for the `#memories`
3
+ * view and for the build's check that every one of them reached an output.
4
+ *
5
+ * A recipe is ACTIVE when the project holds it directly or a co-subscription
6
+ * does (its lockfile kind is `subscribes`); a recipe held only through
7
+ * `depends` is a library and contributes nothing. A memory is a file matched by
8
+ * an include pattern of a manifest `contents` entry of `kind: memories`, and its
9
+ * virtual path is `<namespace>/<recipe>/<path>`, the path being relative to the
10
+ * static base of that pattern.
11
+ *
12
+ * Order: a recipe comes after every pinned recipe it depends on or subscribes
13
+ * to, ties go to the recipe key (bytewise), and the recipes matching
14
+ * `recipes.memories.first` move to the front in that same relative order. Within
15
+ * a recipe, files are in bytewise path order. Recipes matching
16
+ * `recipes.memories.exclude` are not listed.
17
+ */
18
+
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { globSync } from "glob";
22
+ import { compareBytewise } from "../../services/ref-resolver/index.js";
23
+ import { realPathInside } from "../include-resolver.js";
24
+ import { inferGlobBase } from "../markdown-compiler.js";
25
+ import { compileRecipeKeyMatcher } from "./recipe-key-matcher.js";
26
+ import {
27
+ listLockedRecipes,
28
+ readRecipeManifestIn,
29
+ type LockedRecipeLocation,
30
+ } from "./locked-recipes.js";
31
+
32
+ /** One memory file an active recipe publishes. */
33
+ export type MemoryFile = {
34
+ /** The recipe key, `namespace/recipe`. */
35
+ recipe: string;
36
+ /** The path inside the `#memories` view, without the `#memories/` prefix. */
37
+ path: string;
38
+ /** The real file's absolute path. */
39
+ file: string;
40
+ /** The file's path relative to its recipe's directory, for messages. */
41
+ relative: string;
42
+ };
43
+
44
+ /** What listing the memories needs. */
45
+ export type MemoryListingOptions = {
46
+ /** The project's `.sous/` directory. */
47
+ sousDir: string;
48
+ /** The environment to read; decides where the store is. */
49
+ env?: NodeJS.ProcessEnv;
50
+ /** `recipes.memories.first`: recipes whose memories lead. */
51
+ first?: readonly string[] | undefined;
52
+ /** `recipes.memories.exclude`: recipes whose memories are left out. */
53
+ exclude?: readonly string[] | undefined;
54
+ /** The locked recipes, when the caller has already located them. */
55
+ locked?: LockedRecipeLocation[];
56
+ /**
57
+ * Receives a sentence for each memory skipped because its real path is
58
+ * outside its recipe's directory. Defaults to one line on stderr per file.
59
+ */
60
+ onWarning?: (message: string) => void;
61
+ };
62
+
63
+ /** Files already warned about by the default sink, so a repeated listing warns once. */
64
+ const warnedFiles = new Set<string>();
65
+
66
+ /** The default warning sink: one line on stderr, once per file. */
67
+ function warnOnce(message: string): void {
68
+ if (warnedFiles.has(message)) return;
69
+ warnedFiles.add(message);
70
+ process.stderr.write(`Warning: ${message}\n`);
71
+ }
72
+
73
+ /**
74
+ * Orders recipe keys so each comes after every pinned recipe it depends on or
75
+ * subscribes to, ties by key. A lockfile entry's `requestedBy` names the recipes
76
+ * that asked for it, which is the dependency edge read backwards. A cycle (which
77
+ * a resolved lockfile should not hold) leaves its members in key order at the end.
78
+ *
79
+ * @param locked - Every pinned recipe.
80
+ */
81
+ export function dependencyOrder(locked: readonly LockedRecipeLocation[]): string[] {
82
+ // A recipe waits for every pinned recipe that lists it among its holders.
83
+ const waitingOn = new Map<string, Set<string>>();
84
+ for (const recipe of locked) waitingOn.set(recipe.key, new Set());
85
+ for (const dependency of locked) {
86
+ for (const holder of dependency.requestedBy) {
87
+ if (holder !== dependency.key) waitingOn.get(holder)?.add(dependency.key);
88
+ }
89
+ }
90
+
91
+ const ordered: string[] = [];
92
+ const done = new Set<string>();
93
+ const remaining = [...waitingOn.keys()].sort(compareBytewise);
94
+ while (remaining.length > 0) {
95
+ const index = remaining.findIndex((key) => [...waitingOn.get(key)!].every((dep) => done.has(dep)));
96
+ if (index === -1) {
97
+ ordered.push(...remaining);
98
+ break;
99
+ }
100
+ const [key] = remaining.splice(index, 1);
101
+ ordered.push(key!);
102
+ done.add(key!);
103
+ }
104
+ return ordered;
105
+ }
106
+
107
+ /**
108
+ * Lists the memories of every active recipe, in view order, with the recipes
109
+ * that `exclude` names left out.
110
+ *
111
+ * @param options - The project, and the `first` and `exclude` lists.
112
+ */
113
+ export function listMemories(options: MemoryListingOptions): MemoryFile[] {
114
+ const locked =
115
+ options.locked ??
116
+ listLockedRecipes({
117
+ sousDir: options.sousDir,
118
+ ...(options.env === undefined ? {} : { env: options.env }),
119
+ });
120
+ if (locked.length === 0) return [];
121
+
122
+ const isFirst = compileRecipeKeyMatcher(options.first, "recipes.memories.first");
123
+ const isExcluded = compileRecipeKeyMatcher(options.exclude, "recipes.memories.exclude");
124
+ const byKey = new Map(locked.map((recipe) => [recipe.key, recipe]));
125
+
126
+ const order = dependencyOrder(locked);
127
+ const leading = order.filter((key) => isFirst(key));
128
+ const rest = order.filter((key) => !isFirst(key));
129
+
130
+ const files: MemoryFile[] = [];
131
+ for (const key of [...leading, ...rest]) {
132
+ const recipe = byKey.get(key)!;
133
+ if (recipe.kind !== "subscribes" || !recipe.present || isExcluded(key)) continue;
134
+ const manifest = readRecipeManifestIn(recipe.dir);
135
+ if (manifest === undefined) continue;
136
+
137
+ const mine = new Map<string, MemoryFile>();
138
+ for (const content of manifest.contents) {
139
+ if (content.kind !== "memories") continue;
140
+ const ignore = (content.exclude ?? []).map((pattern) => path.join(recipe.dir, pattern));
141
+ for (const include of content.include) {
142
+ const pattern = path.join(recipe.dir, include);
143
+ const base = inferGlobBase(pattern);
144
+ for (const file of globSync(pattern, { absolute: true, ignore, dot: true })) {
145
+ if (!isFile(file)) continue;
146
+ if (!realPathInside(file, recipe.dir)) {
147
+ (options.onWarning ?? warnOnce)(
148
+ `The memory ${file} of the recipe ${key} was left out, because it is a link ` +
149
+ `that leads outside the recipe's directory. Replace the link with the file itself.`
150
+ );
151
+ continue;
152
+ }
153
+ const inside = path.relative(base, file).split(path.sep).join("/");
154
+ mine.set(file, {
155
+ recipe: key,
156
+ path: `${key}/${inside}`,
157
+ file,
158
+ relative: path.relative(recipe.dir, file).split(path.sep).join("/"),
159
+ });
160
+ }
161
+ }
162
+ }
163
+ files.push(...[...mine.values()].sort((a, b) => compareBytewise(a.path, b.path)));
164
+ }
165
+ return files;
166
+ }
167
+
168
+ /** True when the path is a regular file. */
169
+ function isFile(candidate: string): boolean {
170
+ try {
171
+ return fs.statSync(candidate).isFile();
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
@@ -2,11 +2,12 @@
2
2
  * Turning subscribed recipes into compile targets.
3
3
  *
4
4
  * A recipe's manifest lists what it contributes: skills, memories, prompts and
5
- * config layers. Everything but the config layers is compiled exactly the way a
6
- * project's own `entryGlob` target is: the recipe's directory is the glob root,
7
- * the static part of each include pattern is the base the output tree mirrors,
8
- * and the `.tpl.` convention applies unchanged. Nothing about a recipe's files
9
- * is special once they are on disk.
5
+ * config layers. Only skills are copied into the project, compiled exactly the
6
+ * way a project's own `entryGlob` target is: the recipe's directory is the glob
7
+ * root, the static part of each include pattern is the base the output tree
8
+ * mirrors, and the `.tpl.` convention applies unchanged. Memories reach an agent
9
+ * through include lines, never as copied files, and config layers are loaded
10
+ * rather than written.
10
11
  *
11
12
  * Two rules decide what is compiled at all:
12
13
  *
@@ -14,10 +15,10 @@
14
15
  * only through `depends` is fetched, pinned and addressable from the recipe
15
16
  * that declared it, but its files never enter the project's output; that is
16
17
  * the whole difference between the two dependency kinds.
17
- * - A content kind with nowhere to go is skipped, with ONE warning naming the
18
- * `recipeOutputs` config key. Only `skills` has a default, because
19
- * `.claude/skills` is where every agent looks; sous cannot guess where a
20
- * project wants its memories or its prompts.
18
+ * - Skills go to `recipes.skills.outputs`, which defaults to
19
+ * `.claude/skills` because that is where every agent looks. Prompts have no
20
+ * destination yet, so a recipe that publishes them is skipped with ONE
21
+ * warning naming the `recipes` config key.
21
22
  *
22
23
  * A recipe's `config` contents are not handled here. They are config layers, and
23
24
  * config layers are loaded before any of this runs; see
@@ -38,8 +39,11 @@ import {
38
39
  type LockedRecipeLocation,
39
40
  } from "./locked-recipes.js";
40
41
 
41
- /** The content kinds that produce files in a project. `config` is a layer, not a file. */
42
- export const WRITABLE_CONTENT_KINDS = ["skills", "memories", "prompts"] as const;
42
+ /**
43
+ * The content kinds whose files are copied into a project. `config` is a layer,
44
+ * and `memories` are reached by include lines rather than copied.
45
+ */
46
+ export const WRITABLE_CONTENT_KINDS = ["skills"] as const;
43
47
 
44
48
  /** One content kind whose files land somewhere in the project. */
45
49
  export type WritableContentKind = (typeof WRITABLE_CONTENT_KINDS)[number];
@@ -48,7 +52,7 @@ export type WritableContentKind = (typeof WRITABLE_CONTENT_KINDS)[number];
48
52
  export type RecipeTargetOptions = {
49
53
  /** The project's `.sous/` directory. */
50
54
  sousDir: string;
51
- /** The merged project config, read for `recipeOutputs`. */
55
+ /** The merged project config, read for `recipes.skills.outputs`. */
52
56
  settings: Settings;
53
57
  /** The resolved settings scope, used to substitute `${var}` in destinations. */
54
58
  scope?: VarScope;
@@ -98,7 +102,7 @@ export function destinationsFor(
98
102
  kind: WritableContentKind,
99
103
  options: RecipeTargetOptions
100
104
  ): string[] {
101
- const configured = options.settings.recipeOutputs?.[kind];
105
+ const configured = options.settings.recipes?.[kind]?.outputs;
102
106
 
103
107
  if (configured === undefined || configured.length === 0) {
104
108
  // `.claude/skills` is where every agent looks, so it is worth defaulting.
@@ -113,7 +117,7 @@ export function destinationsFor(
113
117
  substituteVarsStrict(
114
118
  destination,
115
119
  options.scope ?? {},
116
- `recipeOutputs.${kind}[${index}]`
120
+ `recipes.${kind}.outputs[${index}]`
117
121
  )
118
122
  )
119
123
  );
@@ -143,7 +147,7 @@ export function buildRecipeTargets(options: RecipeTargetOptions): RecipeTargets
143
147
  if (locked.length === 0) return result;
144
148
 
145
149
  const destinationCache = new Map<WritableContentKind, string[]>();
146
- const kindsWithNowhereToGo = new Set<WritableContentKind>();
150
+ let skippedPrompts = false;
147
151
  const destinations = new Set<string>();
148
152
  const watchDirs = new Set<string>();
149
153
 
@@ -158,6 +162,10 @@ export function buildRecipeTargets(options: RecipeTargetOptions): RecipeTargets
158
162
 
159
163
  for (const content of manifest.contents) {
160
164
  const kind = content.kind;
165
+ if (kind === "prompts") {
166
+ skippedPrompts = true;
167
+ continue;
168
+ }
161
169
  if (!isWritableKind(kind)) continue;
162
170
 
163
171
  if (!destinationCache.has(kind)) {
@@ -165,10 +173,7 @@ export function buildRecipeTargets(options: RecipeTargetOptions): RecipeTargets
165
173
  }
166
174
  const kindDestinations = destinationCache.get(kind)!;
167
175
 
168
- if (kindDestinations.length === 0) {
169
- kindsWithNowhereToGo.add(kind);
170
- continue;
171
- }
176
+ if (kindDestinations.length === 0) continue;
172
177
 
173
178
  for (const destination of kindDestinations) destinations.add(destination);
174
179
  if (recipe.linked) watchDirs.add(recipe.dir);
@@ -198,16 +203,10 @@ export function buildRecipeTargets(options: RecipeTargetOptions): RecipeTargets
198
203
  }
199
204
  }
200
205
 
201
- if (kindsWithNowhereToGo.size > 0) {
202
- const kinds = [...kindsWithNowhereToGo].sort();
206
+ if (skippedPrompts) {
203
207
  result.warnings.push(
204
- `Some subscribed recipes contribute ${kinds.join(" and ")} files, and this ` +
205
- `project has nowhere to put them, so they were skipped.\n` +
206
- `Name a destination directory for each kind under the 'recipeOutputs' key of ` +
207
- `your sous config, for example:\n` +
208
- ` recipeOutputs: { ${kinds
209
- .map((kind) => `${kind}: ["\${projectRoot}/${kind}"]`)
210
- .join(", ")} }`
208
+ `Some subscribed recipes contribute prompts, and the 'recipes' key of your sous ` +
209
+ `config has no destination for prompts yet, so they were skipped.`
211
210
  );
212
211
  }
213
212
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * What was searched when a ref matched nothing.
3
3
  *
4
- * Resolving a ref itself lives in `src/lib/refs/`, which every command shares.
4
+ * Resolving a ref itself lives in `src/services/ref-resolver/`, which every command shares.
5
5
  * All that is left here is the one thing only a repository search can say: which
6
6
  * repositories were actually looked in, and which were trusted but unreadable,
7
7
  * so the reader can tell "the name is wrong" from "the repository holding it
@@ -75,8 +75,15 @@ import {
75
75
  type GatheredDeclaration,
76
76
  } from "../declarations.js";
77
77
  import type { DependencyKind } from "../formats/common.js";
78
- import { isNamedReading, namespaceOfKey, parseRef, refKey } from "../../refs/parse.js";
79
- import { RefSource } from "../../refs/scopes.js";
78
+ import {
79
+ RefSource,
80
+ isNamedRef,
81
+ locationOf,
82
+ namespaceOfKey,
83
+ rangeOf,
84
+ sharedRefResolver,
85
+ shortKey,
86
+ } from "../../../services/ref-resolver/index.js";
80
87
  import type { SettledDependency } from "./settle.js";
81
88
 
82
89
  /** A version that is ready to publish but has no tag yet. */
@@ -547,7 +554,7 @@ function declaredDependencies(
547
554
 
548
555
  let readings;
549
556
  try {
550
- readings = parseRef(written, RefSource.Manifest);
557
+ readings = sharedRefResolver().parse(written, RefSource.Manifest).refs;
551
558
  } catch {
552
559
  // A dependency that does not parse is already reported by validation.
553
560
  continue;
@@ -555,32 +562,33 @@ function declaredDependencies(
555
562
  // Several readings, or a browser path, are settled above or reported by
556
563
  // the settling step; there is nothing more to record here.
557
564
  const parsed = readings[0]!;
558
- if (readings.length !== 1 || !isNamedReading(parsed)) continue;
565
+ if (readings.length !== 1 || !isNamedRef(parsed)) continue;
559
566
 
560
- if (parsed.location !== undefined) {
567
+ const location = locationOf(parsed);
568
+ if (location !== undefined) {
561
569
  // A whole namespace in another repository is read from that repository's
562
570
  // own index by the consumer; only a recipe has a key to record.
563
- if (parsed.recipe !== undefined) {
564
- byKey.set(refKey(parsed), {
571
+ if (parsed.kind === "recipe") {
572
+ byKey.set(shortKey(parsed), {
565
573
  where: "remote",
566
- repo: parsed.location.identity,
567
- range: parsed.range ?? "*",
574
+ repo: location.identity,
575
+ range: rangeOf(parsed) ?? "*",
568
576
  });
569
- cover(refKey(parsed), { written, kind, named: true });
577
+ cover(shortKey(parsed), { written, kind, named: true });
570
578
  }
571
579
  continue;
572
580
  }
573
581
 
574
- if (parsed.recipe !== undefined) {
575
- addSibling(refKey(parsed), parsed.range, true);
576
- cover(refKey(parsed), { written, kind, named: true });
582
+ if (parsed.kind === "recipe") {
583
+ addSibling(shortKey(parsed), rangeOf(parsed), true);
584
+ cover(shortKey(parsed), { written, kind, named: true });
577
585
  continue;
578
586
  }
579
587
 
580
588
  // A whole-namespace dependency means every recipe in that namespace.
581
- namespaces.add(parsed.namespace);
589
+ namespaces.add(parsed.name);
582
590
  for (const entry of validation.recipes) {
583
- if (entry.manifest.namespace !== parsed.namespace) continue;
591
+ if (entry.manifest.namespace !== parsed.name) continue;
584
592
  if (entry.key === recipe.key) continue;
585
593
  addSibling(entry.key, undefined, false);
586
594
  cover(entry.key, { written, kind, named: false });
@@ -24,8 +24,15 @@
24
24
  import path from "node:path";
25
25
  import semver from "semver";
26
26
  import { hashDirectory } from "../store/hash.js";
27
- import { isNamedReading, parseRef, splitRecipeKey } from "../../refs/parse.js";
28
- import { RefSource } from "../../refs/scopes.js";
27
+ import {
28
+ RefSource,
29
+ isNamedRef,
30
+ locationOf,
31
+ namespaceOf,
32
+ sharedRefResolver,
33
+ shortKey,
34
+ splitRecipeKey,
35
+ } from "../../../services/ref-resolver/index.js";
29
36
  import type { RunOptions } from "../providers/git.js";
30
37
  import { listRecipeTags, tagFor, withTaggedTree, type RecipeTag } from "./tags.js";
31
38
  import { nextVersion, type BumpLevel } from "./bump.js";
@@ -346,20 +353,20 @@ export function siblingKeysOf(validation: RepoValidation, key: string): string[]
346
353
  for (const written of declared) {
347
354
  let readings;
348
355
  try {
349
- readings = parseRef(written, RefSource.Manifest);
356
+ readings = sharedRefResolver().parse(written, RefSource.Manifest).refs;
350
357
  } catch {
351
358
  continue;
352
359
  }
353
360
  const parsed = readings[0]!;
354
- if (!isNamedReading(parsed) || parsed.location !== undefined) continue;
361
+ if (!isNamedRef(parsed) || locationOf(parsed) !== undefined) continue;
355
362
 
356
- if (parsed.recipe !== undefined) {
357
- keys.push(`${parsed.namespace}/${parsed.recipe}`);
363
+ if (parsed.kind === "recipe") {
364
+ keys.push(shortKey(parsed));
358
365
  continue;
359
366
  }
360
367
  // A whole-namespace dependency means every recipe in it.
361
368
  for (const entry of validation.recipes) {
362
- if (entry.manifest.namespace === parsed.namespace && entry.key !== key) {
369
+ if (entry.manifest.namespace === namespaceOf(parsed)!.name && entry.key !== key) {
363
370
  keys.push(entry.key);
364
371
  }
365
372
  }
@@ -21,26 +21,17 @@
21
21
  * naming the spellings that read one way (the `/*` form for the namespace).
22
22
  */
23
23
 
24
- import { parseIndexFile, type IndexFile } from "../formats/index-file.js";
25
- import { INDEX_FILENAME } from "../formats/common.js";
24
+ import type { ProviderOptions, RepoProvider } from "../providers/index.js";
26
25
  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";
26
+ FetchedIndexLookup,
27
+ RefSource,
28
+ locationOf,
29
+ namespaceOf,
30
+ rangeOf,
31
+ sharedRefResolver,
32
+ shortKey,
33
+ type SousRef,
34
+ } from "../../../services/ref-resolver/index.js";
44
35
  import { describeError, type RepoValidation, type ValidationProblem } from "./validate.js";
45
36
 
46
37
  /** What one dependency settled on. */
@@ -78,8 +69,11 @@ export type SettleOptions = {
78
69
  *
79
70
  * @param readings - Every reading of the dependency.
80
71
  */
81
- export function needsSettling(readings: RefReading[]): boolean {
82
- return readings.length > 1 || readings.some(isBrowsedReading);
72
+ export function needsSettling(readings: SousRef[]): boolean {
73
+ return (
74
+ readings.length > 1 ||
75
+ readings.some((reading) => reading.kind === "repo" && reading.browsed !== undefined)
76
+ );
83
77
  }
84
78
 
85
79
  /**
@@ -95,19 +89,13 @@ export async function settleDependencyLocations(
95
89
  validation: RepoValidation,
96
90
  options: SettleOptions = {}
97
91
  ): Promise<SettleResult> {
98
- const providers = options.providers ?? builtInProviders();
99
92
  const settled = new Map<string, SettledDependency>();
100
93
  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
- };
94
+ const lookup = new FetchedIndexLookup({
95
+ subject: "dependency",
96
+ ...(options.providers === undefined ? {} : { providers: options.providers }),
97
+ ...(options.providerOptions === undefined ? {} : { providerOptions: options.providerOptions }),
98
+ });
111
99
 
112
100
  for (const recipe of validation.recipes) {
113
101
  const declared = [...(recipe.manifest.depends ?? []), ...(recipe.manifest.subscribes ?? [])];
@@ -115,9 +103,9 @@ export async function settleDependencyLocations(
115
103
  const trimmed = written.trim();
116
104
  if (settled.has(trimmed)) continue;
117
105
 
118
- let readings: RefReading[];
106
+ let readings: SousRef[];
119
107
  try {
120
- readings = parseRef(trimmed, RefSource.Manifest, providers);
108
+ readings = sharedRefResolver().parse(trimmed, RefSource.Manifest).refs;
121
109
  } catch {
122
110
  // Validation already reported a dependency that does not parse.
123
111
  continue;
@@ -126,7 +114,7 @@ export async function settleDependencyLocations(
126
114
 
127
115
  const where = `${recipe.path} ('${trimmed}')`;
128
116
  try {
129
- settled.set(trimmed, await settleOne(trimmed, readings, indexOf, providers));
117
+ settled.set(trimmed, await settleOne(readings, lookup));
130
118
  } catch (error) {
131
119
  problems.push({ level: "error", where, message: describeError(error) });
132
120
  }
@@ -137,101 +125,30 @@ export async function settleDependencyLocations(
137
125
  }
138
126
 
139
127
  /**
140
- * Settles one dependency: fetches the index behind each reading, and keeps the
141
- * one reading that publishes what it names.
128
+ * Settles one dependency: the one reading whose repository's index publishes
129
+ * what it names.
142
130
  *
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.
131
+ * @param readings - Every reading of the dependency.
132
+ * @param lookup - The fetched indexes.
147
133
  */
148
134
  async function settleOne(
149
- written: string,
150
- readings: RefReading[],
151
- indexOf: (location: RefLocation) => Promise<IndexFile>,
152
- providers: RepoProvider[]
135
+ readings: SousRef[],
136
+ lookup: FetchedIndexLookup
153
137
  ): 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]!;
138
+ const { ref: choice } = await lookup.settle(readings);
139
+ const location = locationOf(choice)!;
140
+ const index = await lookup.indexAt(location);
141
+ const namespace = namespaceOf(choice)?.name;
200
142
  const keys =
201
- choice.recipe === undefined
143
+ choice.kind === "namespace"
202
144
  ? Object.keys(index.recipes)
203
- .filter((key) => key.startsWith(`${choice.namespace}/`))
145
+ .filter((key) => key.startsWith(`${namespace}/`))
204
146
  .sort()
205
- : [refKey(choice)];
206
- const range = "range" in found[0]!.reading ? found[0]!.reading.range : undefined;
147
+ : [shortKey(choice)];
148
+ const range = readings.map(rangeOf).find((written) => written !== undefined);
207
149
  return {
208
- identity: choice.location!.identity,
150
+ identity: location.identity,
209
151
  keys,
210
152
  ...(range === undefined ? {} : { range }),
211
153
  };
212
154
  }
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
- }