@sous-io/sous 0.1.1 → 0.2.1

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 (205) hide show
  1. package/README.md +115 -35
  2. package/bin/run.js +10 -1
  3. package/docs/markdown/README.md +27 -0
  4. package/docs/markdown/_sidebar.md +18 -0
  5. package/docs/markdown/commands.md +308 -0
  6. package/docs/markdown/config-discovery.md +74 -0
  7. package/docs/markdown/config-inspection.md +69 -0
  8. package/docs/markdown/config-layers.md +92 -0
  9. package/docs/markdown/config-variables.md +79 -0
  10. package/docs/markdown/configuration.md +71 -0
  11. package/docs/markdown/design-principles.md +59 -0
  12. package/docs/markdown/repositories-authoring.md +409 -0
  13. package/docs/markdown/repositories-consuming.md +580 -0
  14. package/docs/markdown/repositories-file-formats.md +1084 -0
  15. package/docs/markdown/repositories-variables.md +387 -0
  16. package/docs/markdown/repositories.md +303 -0
  17. package/docs/markdown/skill-categories.md +58 -0
  18. package/package.json +72 -8
  19. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/SKILL.tpl.md +20 -20
  20. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/about-something.md +2 -2
  21. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/do-something.md +1 -1
  22. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/advanced-patterns.md +6 -6
  23. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/commands.md +5 -5
  24. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/frontmatter.md +3 -3
  25. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/SKILL.tpl.md +40 -25
  26. package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +70 -0
  27. package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +75 -0
  28. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/create-skill/SKILL.tpl.md +8 -9
  29. package/recipes/core/sous-skills/sous.recipe.yaml +45 -0
  30. package/sous.config.schema.json +337 -0
  31. package/src/base-command.ts +220 -67
  32. package/src/commands/build.ts +150 -73
  33. package/src/commands/clear.ts +23 -15
  34. package/src/commands/compile.ts +74 -16
  35. package/src/commands/config/get.ts +110 -0
  36. package/src/commands/config/show.ts +32 -0
  37. package/src/commands/config/validate.ts +53 -0
  38. package/src/commands/help.ts +46 -0
  39. package/src/commands/launch.ts +36 -14
  40. package/src/commands/lock/rebuild.ts +241 -0
  41. package/src/commands/lock/show.ts +115 -0
  42. package/src/commands/namespace/list.ts +117 -0
  43. package/src/commands/namespace/show.ts +110 -0
  44. package/src/commands/prune.ts +3 -11
  45. package/src/commands/recipe/list.ts +95 -0
  46. package/src/commands/recipe/show.ts +301 -0
  47. package/src/commands/repo/add.ts +145 -0
  48. package/src/commands/repo/gc.ts +172 -0
  49. package/src/commands/repo/init.ts +136 -0
  50. package/src/commands/repo/link.ts +500 -0
  51. package/src/commands/repo/list.ts +179 -0
  52. package/src/commands/repo/release.ts +625 -0
  53. package/src/commands/repo/remove.ts +193 -0
  54. package/src/commands/repo/search.ts +189 -0
  55. package/src/commands/repo/submit.ts +133 -0
  56. package/src/commands/repo/unlink.ts +147 -0
  57. package/src/commands/subscription/add.ts +285 -0
  58. package/src/commands/subscription/list.ts +129 -0
  59. package/src/commands/subscription/remove.ts +181 -0
  60. package/src/commands/vars/ask.ts +374 -0
  61. package/src/commands/vars/index.ts +79 -0
  62. package/src/commands/vars/list.ts +67 -0
  63. package/src/commands/vars/show.ts +77 -0
  64. package/src/config-command.ts +30 -0
  65. package/src/lib/build-service.ts +206 -54
  66. package/src/lib/config-discovery.ts +220 -27
  67. package/src/lib/config-inspect.ts +145 -0
  68. package/src/lib/config-kernel.mjs +377 -0
  69. package/src/lib/config-schema.ts +361 -0
  70. package/src/lib/env-file.ts +328 -0
  71. package/src/lib/env-local.ts +18 -1
  72. package/src/lib/errors.ts +32 -0
  73. package/src/lib/include-resolver.ts +108 -15
  74. package/src/lib/interactive.ts +165 -0
  75. package/src/lib/markdown-compiler.ts +118 -37
  76. package/src/lib/package-info.ts +25 -0
  77. package/src/lib/pid-service.ts +32 -21
  78. package/src/lib/refs/find.ts +589 -0
  79. package/src/lib/refs/index.ts +12 -0
  80. package/src/lib/refs/pick.ts +147 -0
  81. package/src/lib/refs/scopes.ts +61 -0
  82. package/src/lib/repos/catalog-display.ts +116 -0
  83. package/src/lib/repos/catalog-inputs.ts +160 -0
  84. package/src/lib/repos/catalog.ts +722 -0
  85. package/src/lib/repos/core-recipe.ts +105 -0
  86. package/src/lib/repos/defaults.ts +175 -0
  87. package/src/lib/repos/formats/common.ts +389 -0
  88. package/src/lib/repos/formats/index-file.ts +215 -0
  89. package/src/lib/repos/formats/links-map.ts +96 -0
  90. package/src/lib/repos/formats/lockfile.ts +167 -0
  91. package/src/lib/repos/formats/patterns.ts +57 -0
  92. package/src/lib/repos/formats/recipe-manifest.ts +395 -0
  93. package/src/lib/repos/formats/repo-manifest.ts +88 -0
  94. package/src/lib/repos/formats/store-entry.ts +84 -0
  95. package/src/lib/repos/freshness.ts +208 -0
  96. package/src/lib/repos/git-clone.ts +312 -0
  97. package/src/lib/repos/identity.ts +89 -0
  98. package/src/lib/repos/index.ts +58 -0
  99. package/src/lib/repos/links.ts +353 -0
  100. package/src/lib/repos/load-manifest.ts +236 -0
  101. package/src/lib/repos/lock-service.ts +453 -0
  102. package/src/lib/repos/locked-namespace-resolver.ts +90 -0
  103. package/src/lib/repos/locked-recipes.ts +254 -0
  104. package/src/lib/repos/managed-layer.ts +422 -0
  105. package/src/lib/repos/namespace-resolver.ts +370 -0
  106. package/src/lib/repos/providers/base.ts +206 -0
  107. package/src/lib/repos/providers/git.ts +233 -0
  108. package/src/lib/repos/providers/github.ts +294 -0
  109. package/src/lib/repos/providers/gitlab.ts +263 -0
  110. package/src/lib/repos/providers/http.ts +102 -0
  111. package/src/lib/repos/providers/index-cache.ts +382 -0
  112. package/src/lib/repos/providers/index.ts +106 -0
  113. package/src/lib/repos/providers/local.ts +391 -0
  114. package/src/lib/repos/providers/provider.ts +401 -0
  115. package/src/lib/repos/recipe-config-layers.ts +287 -0
  116. package/src/lib/repos/recipe-targets.ts +223 -0
  117. package/src/lib/repos/ref-search.ts +46 -0
  118. package/src/lib/repos/ref.ts +513 -0
  119. package/src/lib/repos/reference-report.ts +122 -0
  120. package/src/lib/repos/release/bump.ts +161 -0
  121. package/src/lib/repos/release/git-state.ts +305 -0
  122. package/src/lib/repos/release/index-builder.ts +635 -0
  123. package/src/lib/repos/release/index.ts +16 -0
  124. package/src/lib/repos/release/plan.ts +512 -0
  125. package/src/lib/repos/release/submit-service.ts +496 -0
  126. package/src/lib/repos/release/tags.ts +243 -0
  127. package/src/lib/repos/release/validate.ts +463 -0
  128. package/src/lib/repos/resolver.ts +789 -0
  129. package/src/lib/repos/scaffold/index.ts +238 -0
  130. package/src/lib/repos/scaffold/templates.ts +415 -0
  131. package/src/lib/repos/seed.ts +414 -0
  132. package/src/lib/repos/store/contract.ts +64 -0
  133. package/src/lib/repos/store/hash.ts +114 -0
  134. package/src/lib/repos/store/recipe-store.ts +599 -0
  135. package/src/lib/repos/store/settings.ts +58 -0
  136. package/src/lib/repos/subscription-service.ts +2678 -0
  137. package/src/lib/repos/trust.ts +447 -0
  138. package/src/lib/settings.ts +546 -189
  139. package/src/lib/sous-home.ts +104 -0
  140. package/src/lib/state.ts +52 -20
  141. package/src/lib/vars/ask.ts +1152 -0
  142. package/src/lib/vars/definition-source.ts +252 -0
  143. package/src/lib/vars/display.ts +233 -0
  144. package/src/lib/vars/index.ts +18 -0
  145. package/src/lib/vars/ladder.ts +282 -0
  146. package/src/lib/vars/mappings.ts +265 -0
  147. package/src/lib/vars/names.ts +94 -0
  148. package/src/lib/vars/preanswers.ts +395 -0
  149. package/src/lib/vars/question-plan.ts +218 -0
  150. package/src/lib/vars/report.ts +228 -0
  151. package/src/lib/vars/safe-regex.ts +235 -0
  152. package/src/lib/vars/validate.ts +312 -0
  153. package/src/lib/watch-loop.ts +148 -0
  154. package/src/templating/init-liquid-engine.ts +58 -16
  155. package/src/utils/choice-prompt.ts +143 -0
  156. package/src/utils/command-errors.ts +186 -0
  157. package/src/utils/command-help.ts +45 -0
  158. package/src/utils/confirm-prompt.ts +110 -0
  159. package/src/utils/flags.ts +153 -0
  160. package/src/utils/formatting.ts +540 -55
  161. package/src/utils/prompts.ts +35 -1
  162. package/src/utils/sous-directory.ts +245 -0
  163. package/src/utils/table.ts +603 -0
  164. package/src/utils/value-prompt.ts +119 -0
  165. package/shared-prompts/_partials/resume-task.md +0 -51
  166. package/shared-prompts/_partials/sub-agent-delegation.md +0 -32
  167. package/shared-prompts/_partials/update-task-file.md +0 -52
  168. package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +0 -52
  169. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +0 -102
  170. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +0 -81
  171. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +0 -126
  172. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +0 -92
  173. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +0 -61
  174. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +0 -65
  175. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +0 -96
  176. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +0 -104
  177. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +0 -243
  178. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +0 -148
  179. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +0 -383
  180. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +0 -267
  181. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +0 -56
  182. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +0 -169
  183. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +0 -59
  184. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +0 -25
  185. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +0 -140
  186. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +0 -140
  187. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +0 -1
  188. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +0 -185
  189. package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +0 -52
  190. package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +0 -59
  191. package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +0 -47
  192. package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +0 -26
  193. package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +0 -58
  194. package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +0 -27
  195. package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +0 -34
  196. package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +0 -51
  197. package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +0 -122
  198. package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +0 -80
  199. package/shared-prompts/skills/task-files/go/SKILL.tpl.md +0 -14
  200. package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +0 -13
  201. package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +0 -93
  202. package/shared-prompts/skills/task-files/update/SKILL.tpl.md +0 -14
  203. package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +0 -13
  204. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/substitutions.md +0 -0
  205. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/references/liquid-filters.md +0 -0
@@ -0,0 +1,789 @@
1
+ /**
2
+ * The resolver.
3
+ *
4
+ * Given what a project asked for (its subscriptions, or the refs named on a
5
+ * command line), the resolver decides exactly which recipe versions that means.
6
+ * It works the way apt does: a ref is looked up across the cached indexes of
7
+ * EVERY added repository at once, and a ref that genuinely exists in more than
8
+ * one of them is an error demanding the qualified form, never a silent
9
+ * first-match-wins.
10
+ *
11
+ * It then walks the dependency closure. Each resolved recipe's manifest names
12
+ * more refs, through `depends` (fetched and addressable, but not added to the
13
+ * project) and `subscribes` (co-subscribed with full semantics), and those refs
14
+ * resolve the same way. The manifest lives inside the recipe's own files, so
15
+ * the caller supplies a loader; that is also the seam where a caller decides
16
+ * whether it is willing to fetch anything at this point.
17
+ *
18
+ * Two things the resolver deliberately does NOT do: it never downloads
19
+ * anything, and it never resolves against a repository the project has not
20
+ * added. A dependency on an unknown repository comes back as a `MissingRepo`
21
+ * carrying its provenance, so the trust layer can ask about it by name.
22
+ */
23
+
24
+ import semver from "semver";
25
+ import { ConfigError } from "../errors.js";
26
+ import type { IndexFile } from "./formats/index-file.js";
27
+ import type { LockKind } from "./formats/lockfile.js";
28
+ import type { RecipeManifest } from "./formats/recipe-manifest.js";
29
+ import {
30
+ dependencyRefKey,
31
+ dependencyRepoUrl,
32
+ formatRef,
33
+ parseDependencyRef,
34
+ refKey,
35
+ type DependencyRef,
36
+ type ParsedRef,
37
+ } from "./ref.js";
38
+ import { shortNameFromIdentity } from "./identity.js";
39
+ import type { IndexDependency } from "./formats/index-file.js";
40
+
41
+ /** The `requestedBy` holder meaning "the project asked for this directly". */
42
+ export const PROJECT_REQUESTER = "project";
43
+
44
+ /** One thing to resolve. */
45
+ export type RefRequest = {
46
+ /** The ref, already parsed. */
47
+ ref: ParsedRef;
48
+ /** Whether prerelease versions may take part in range matching. */
49
+ prerelease?: boolean;
50
+ /**
51
+ * Who asked: the literal "project" for a subscription the project holds
52
+ * directly, or the recipe key of the recipe whose manifest asked.
53
+ */
54
+ requestedBy: string;
55
+ /**
56
+ * Whether this is a co-subscription or a build dependency. Defaults to
57
+ * "subscribes", which is what a project's own subscriptions are.
58
+ */
59
+ kind?: LockKind;
60
+ };
61
+
62
+ /** What the resolver needs to know about a repository the project has added. */
63
+ export type ResolverRepo = {
64
+ /** Where the repository lives. */
65
+ url: string;
66
+ /**
67
+ * The repository's canonical identity, `<host>/<owner path>/<name>`. A
68
+ * dependency that names another repository names it by location, so this is
69
+ * what decides whether the project has already added it, whatever short name
70
+ * the project gave it.
71
+ */
72
+ identity: string;
73
+ /** The provider it names, when it names one. */
74
+ provider?: string;
75
+ /** Whether it prefers a newer in-range version over the locked one. */
76
+ alwaysPull?: boolean;
77
+ };
78
+
79
+ /**
80
+ * Loads a resolved recipe's manifest, which is what names its dependencies.
81
+ * Returning undefined means "not available", and the recipe is reported under
82
+ * `missingManifests` rather than having its dependencies walked.
83
+ */
84
+ export type RecipeManifestLoader = (
85
+ recipe: ResolvedRecipe
86
+ ) => Promise<RecipeManifest | undefined> | RecipeManifest | undefined;
87
+
88
+ /** Everything the resolver reads. */
89
+ export type ResolveContext = {
90
+ /** The cached index of every added repository, keyed by its short name. */
91
+ indexes: Map<string, IndexFile>;
92
+ /** The added repositories, keyed by short name; the trust list, in other words. */
93
+ repos: Record<string, ResolverRepo>;
94
+ /** How a resolved recipe's manifest is loaded. */
95
+ loadManifest: RecipeManifestLoader;
96
+ /** Whether prereleases are allowed when a request does not say. Defaults to false. */
97
+ prerelease?: boolean;
98
+ };
99
+
100
+ /** One recipe version the resolver settled on. */
101
+ export type ResolvedRecipe = {
102
+ /** The recipe key, `namespace/recipe`. */
103
+ key: string;
104
+ namespace: string;
105
+ name: string;
106
+ /** The short name of the repository it resolves in, as this project calls it. */
107
+ repo: string;
108
+ /** That repository's canonical identity, which the machine-wide store is keyed by. */
109
+ identity: string;
110
+ /** The exact version chosen. */
111
+ version: string;
112
+ /** The content hash the index publishes for that version. */
113
+ hash: string;
114
+ /** The git tag carrying that version. */
115
+ tag: string;
116
+ /** The recipe folder, relative to the repository root. */
117
+ path: string;
118
+ /** Whether anything holds it as a co-subscription; otherwise a build dependency. */
119
+ kind: LockKind;
120
+ /** Everyone holding it: "project", and the key of every recipe that asked. */
121
+ requestedBy: string[];
122
+ /** Every range that had to be satisfied at once, with who asked for it. */
123
+ ranges: Array<{ range: string; requestedBy: string }>;
124
+ /** Whether prereleases took part in the match. */
125
+ prerelease: boolean;
126
+ /**
127
+ * What the repository's index says this exact version depends on, when the
128
+ * index records it. These are the versions the release resolved, so a
129
+ * dependency of an indexed recipe is installed at the version it was
130
+ * published against rather than at whatever its range would reach today.
131
+ */
132
+ dependencies?: Record<string, IndexDependency>;
133
+ };
134
+
135
+ /** A repository something needs that the project has not added. */
136
+ export type MissingRepo = {
137
+ /**
138
+ * The short name to record it under: the one a ref qualified it with, or one
139
+ * derived from its location when a dependency named it by URL.
140
+ */
141
+ name: string;
142
+ /** Its URL, which a dependency's locator carries. */
143
+ url?: string;
144
+ /** Its canonical identity, when the dependency named a location. */
145
+ identity?: string;
146
+ /** The provider its locator named, when it named one. */
147
+ provider?: string;
148
+ /** Every ref that needs it, and who asked for that ref. */
149
+ requiredBy: Array<{ ref: string; requestedBy: string }>;
150
+ };
151
+
152
+ /** What a resolution produced. */
153
+ export type ResolveResult = {
154
+ /** Every recipe version to install, in a stable order (by key). */
155
+ resolved: ResolvedRecipe[];
156
+ /** Repositories a dependency needs that the project has not added. */
157
+ missingRepos: MissingRepo[];
158
+ /** Resolved recipes whose manifest the loader could not produce. */
159
+ missingManifests: string[];
160
+ /** Any dependency cycle found, as the chain of recipe keys forming it. */
161
+ cycles: string[][];
162
+ };
163
+
164
+ /** A pending piece of work: one ref to resolve on behalf of one holder. */
165
+ type WorkItem = RefRequest & {
166
+ kind: LockKind;
167
+ /**
168
+ * Where the ref said the recipe lives, when a manifest named another
169
+ * repository by location. The project may already have added that repository
170
+ * under any short name at all, so it is matched by identity.
171
+ */
172
+ remote?: {
173
+ /** The repository's canonical identity. */
174
+ identity: string;
175
+ /** Its HTTPS location, which is what adding it would be handed. */
176
+ url: string;
177
+ /** The provider the locator named. */
178
+ provider?: string;
179
+ /** The dependency exactly as the manifest wrote it, for messages. */
180
+ written: string;
181
+ };
182
+ };
183
+
184
+ /**
185
+ * Turns one entry of a manifest's `depends` or `subscribes` into a piece of
186
+ * work.
187
+ *
188
+ * Two things are decided here. A SIBLING ref resolves inside the declaring
189
+ * recipe's own repository, never across the others, because that is what
190
+ * writing it without a location means. And when the repository's index records
191
+ * what this exact version of the parent was released against, that exact
192
+ * version is what gets asked for, rather than whatever the declared range would
193
+ * reach today.
194
+ *
195
+ * @param written - The dependency as the manifest wrote it.
196
+ * @param parent - The recipe whose manifest declared it.
197
+ * @param kind - Whether it was declared as a dependency or a co-subscription.
198
+ */
199
+ function dependencyRequest(
200
+ written: string,
201
+ parent: ResolvedRecipe,
202
+ kind: LockKind
203
+ ): WorkItem {
204
+ const parsed = parseDependencyRef(written);
205
+ const pinned = parent.dependencies?.[dependencyRefKey(parsed)];
206
+
207
+ const ref: ParsedRef = { namespace: parsed.namespace };
208
+ if (parsed.recipe !== undefined) {
209
+ ref.recipe = parsed.recipe;
210
+ const range = pinned?.version ?? parsed.range ?? pinned?.range;
211
+ if (range !== undefined) ref.range = range;
212
+ }
213
+
214
+ const base = {
215
+ requestedBy: parent.key,
216
+ kind,
217
+ ...(parent.prerelease ? { prerelease: true } : {}),
218
+ };
219
+
220
+ if (parsed.kind === "sibling") {
221
+ return { ref: { ...ref, repo: parent.repo }, ...base };
222
+ }
223
+
224
+ return {
225
+ ref,
226
+ ...base,
227
+ remote: {
228
+ identity: pinned?.repo ?? parsed.canonicalRepo!,
229
+ url: dependencyRepoUrl(parsed)!,
230
+ ...(parsed.provider === undefined ? {} : { provider: parsed.provider }),
231
+ written: written.trim(),
232
+ },
233
+ };
234
+ }
235
+
236
+ /**
237
+ * The short name of the added repository with this identity, or undefined when
238
+ * the project has added none. Short names are a project's own labels, so the
239
+ * identity is what a dependency is matched on.
240
+ *
241
+ * @param repos - The repositories the project has added.
242
+ * @param identity - The canonical identity to look for.
243
+ */
244
+ function repoNamedByIdentity(
245
+ repos: Record<string, ResolverRepo>,
246
+ identity: string
247
+ ): string | undefined {
248
+ for (const [name, entry] of Object.entries(repos)) {
249
+ if (entry.identity === identity) return name;
250
+ }
251
+ return undefined;
252
+ }
253
+
254
+ /**
255
+ * A short name for a repository the project has not added yet, derived from its
256
+ * location and made unique against the names already in use.
257
+ *
258
+ * @param repos - The repositories the project has added.
259
+ * @param identity - The canonical identity of the repository being named.
260
+ */
261
+ function proposeRepoName(
262
+ repos: Record<string, ResolverRepo>,
263
+ identity: string
264
+ ): string {
265
+ const base = shortNameFromIdentity(identity);
266
+ if (!Object.hasOwn(repos, base)) return base;
267
+ for (let suffix = 2; ; suffix++) {
268
+ const candidate = `${base}-${suffix}`;
269
+ if (!Object.hasOwn(repos, candidate)) return candidate;
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Resolves a set of refs and the whole dependency closure beneath them.
275
+ *
276
+ * @param requests - What the project (or the command line) asked for.
277
+ * @param context - The cached indexes, the added repositories and a manifest loader.
278
+ */
279
+ export async function resolveRefs(
280
+ requests: RefRequest[],
281
+ context: ResolveContext
282
+ ): Promise<ResolveResult> {
283
+ const resolved = new Map<string, ResolvedRecipe>();
284
+ const missingRepos = new Map<string, MissingRepo>();
285
+ const missingManifests = new Set<string>();
286
+ const cycles: string[][] = [];
287
+ /** Which recipe pulled each recipe in, used to find a cycle's chain. */
288
+ const parents = new Map<string, string>();
289
+ /** Recipes whose manifest has already been walked, at the version noted. */
290
+ const walked = new Map<string, string>();
291
+
292
+ const queue: WorkItem[] = requests.map((request) => ({
293
+ ...request,
294
+ kind: request.kind ?? "subscribes",
295
+ }));
296
+
297
+ while (queue.length > 0) {
298
+ const item = queue.shift()!;
299
+
300
+ // A ref naming a repository the project has not added never resolves and
301
+ // never downloads anything; it comes back as a missing repository instead,
302
+ // so the trust layer can ask about it by name.
303
+ const qualifier = item.ref.repo;
304
+ if (qualifier !== undefined && !Object.hasOwn(context.repos, qualifier)) {
305
+ recordMissingRepo(missingRepos, {
306
+ name: qualifier,
307
+ requiredBy: [{ ref: formatRef(item.ref), requestedBy: item.requestedBy }],
308
+ });
309
+ continue;
310
+ }
311
+
312
+ // A dependency that named another repository by location is matched on that
313
+ // location, so a project that already added it under some other short name
314
+ // resolves against the copy it has. One it has not added carries its URL and
315
+ // its provider, which is everything the trust round needs to offer to add it.
316
+ if (item.remote !== undefined) {
317
+ const added = repoNamedByIdentity(context.repos, item.remote.identity);
318
+ if (added === undefined) {
319
+ recordMissingRepo(missingRepos, {
320
+ name: proposeRepoName(context.repos, item.remote.identity),
321
+ url: item.remote.url,
322
+ identity: item.remote.identity,
323
+ ...(item.remote.provider === undefined ? {} : { provider: item.remote.provider }),
324
+ requiredBy: [{ ref: item.remote.written, requestedBy: item.requestedBy }],
325
+ });
326
+ continue;
327
+ }
328
+ item.ref = { ...item.ref, repo: added };
329
+ }
330
+
331
+ if (item.ref.recipe === undefined) {
332
+ queue.push(...expandNamespace(item, context));
333
+ continue;
334
+ }
335
+
336
+ const recipe = resolveRecipeRef(item, context, resolved);
337
+ resolved.set(recipe.key, recipe);
338
+
339
+ if (item.requestedBy !== PROJECT_REQUESTER && !parents.has(recipe.key)) {
340
+ parents.set(recipe.key, item.requestedBy);
341
+ }
342
+
343
+ // Walking a manifest is only worth doing once per version. A second holder
344
+ // of an already-walked version adds itself and stops there; that is also
345
+ // what keeps a dependency cycle finite.
346
+ if (walked.get(recipe.key) === recipe.version) {
347
+ const cycle = findCycle(recipe.key, item.requestedBy, parents);
348
+ if (cycle !== undefined) cycles.push(cycle);
349
+ continue;
350
+ }
351
+ walked.set(recipe.key, recipe.version);
352
+
353
+ const manifest = await context.loadManifest(recipe);
354
+ if (manifest === undefined) {
355
+ missingManifests.add(recipe.key);
356
+ continue;
357
+ }
358
+
359
+ for (const [kind, refs] of [
360
+ ["depends", manifest.depends ?? []],
361
+ ["subscribes", manifest.subscribes ?? []],
362
+ ] as Array<[LockKind, string[]]>) {
363
+ for (const written of refs) {
364
+ queue.push(dependencyRequest(written, recipe, kind));
365
+ }
366
+ }
367
+ }
368
+
369
+ // A recipe whose version was narrowed by a later holder has had TWO of its
370
+ // versions walked, and the dependencies discovered from the version that was
371
+ // replaced are still sitting in `resolved`. Walk the closure once more over
372
+ // the versions actually settled on, and keep only what that reaches.
373
+ await keepOnlyReachable(resolved, context);
374
+
375
+ const ordered = [...resolved.values()].sort((left, right) =>
376
+ left.key < right.key ? -1 : left.key > right.key ? 1 : 0
377
+ );
378
+ for (const recipe of ordered) recipe.requestedBy.sort();
379
+
380
+ return {
381
+ resolved: ordered,
382
+ missingRepos: [...missingRepos.values()].sort((left, right) =>
383
+ left.name < right.name ? -1 : 1
384
+ ),
385
+ missingManifests: [...missingManifests].sort(),
386
+ cycles,
387
+ };
388
+ }
389
+
390
+ /**
391
+ * Drops every resolved recipe the settled closure no longer reaches, and trims
392
+ * the holders and ranges of the ones that stay.
393
+ *
394
+ * The walk resolves refs in the order it meets them, so a recipe can be walked
395
+ * at one version and then walked again at a lower one once a second holder
396
+ * narrows its range. The lower version is what the closure settles on, but the
397
+ * dependencies discovered from the higher one are already in `resolved`, held by
398
+ * a parent that no longer declares them. Installing those is wrong twice over:
399
+ * they are content nothing asked for, and the lockfile would record a holder
400
+ * that does not hold them.
401
+ *
402
+ * So the closure is walked once more over the versions actually settled on. This
403
+ * re-reads manifests that have already been read, which the loader serves from
404
+ * the store; nothing is fetched. A recipe the loader cannot produce a manifest
405
+ * for keeps everything it reached, since the alternative is dropping a
406
+ * dependency because a manifest was unreadable.
407
+ *
408
+ * Versions are NOT re-picked. Trimming can only remove constraints, so the
409
+ * version already chosen still satisfies every holder that remains; re-picking
410
+ * could raise it and undo the narrowing that made this pass necessary.
411
+ *
412
+ * @param resolved - What the walk produced, edited in place.
413
+ * @param context - The manifest loader.
414
+ */
415
+ async function keepOnlyReachable(
416
+ resolved: Map<string, ResolvedRecipe>,
417
+ context: ResolveContext
418
+ ): Promise<void> {
419
+ /** Who declares each key, and under which kind, in the settled closure. */
420
+ const holders = new Map<string, Map<string, LockKind>>();
421
+
422
+ /** Records one declaration, and reports whether the child is newly reached. */
423
+ const declare = (parent: string, child: string, kind: LockKind): boolean => {
424
+ const existing = holders.get(child);
425
+ if (existing === undefined) {
426
+ holders.set(child, new Map([[parent, kind]]));
427
+ return true;
428
+ }
429
+ const previous = existing.get(parent);
430
+ existing.set(parent, previous === "subscribes" || kind === "subscribes" ? "subscribes" : kind);
431
+ return false;
432
+ };
433
+
434
+ const queue: string[] = [];
435
+ for (const recipe of resolved.values()) {
436
+ if (!recipe.requestedBy.includes(PROJECT_REQUESTER)) continue;
437
+ if (declare(PROJECT_REQUESTER, recipe.key, "subscribes")) queue.push(recipe.key);
438
+ }
439
+
440
+ while (queue.length > 0) {
441
+ const key = queue.shift()!;
442
+ const recipe = resolved.get(key);
443
+ if (recipe === undefined) continue;
444
+
445
+ let manifest;
446
+ try {
447
+ manifest = await context.loadManifest(recipe);
448
+ } catch {
449
+ manifest = undefined;
450
+ }
451
+
452
+ if (manifest === undefined) {
453
+ // An unreadable manifest is already reported as a missing manifest. Keep
454
+ // everything this recipe held rather than dropping a dependency over it.
455
+ for (const other of resolved.values()) {
456
+ if (!other.requestedBy.includes(key)) continue;
457
+ const kind = other.kind;
458
+ if (declare(key, other.key, kind)) queue.push(other.key);
459
+ }
460
+ continue;
461
+ }
462
+
463
+ for (const [kind, refs] of [
464
+ ["depends", manifest.depends ?? []],
465
+ ["subscribes", manifest.subscribes ?? []],
466
+ ] as Array<[LockKind, string[]]>) {
467
+ for (const written of refs) {
468
+ let parsed: DependencyRef;
469
+ try {
470
+ parsed = parseDependencyRef(written);
471
+ } catch {
472
+ continue;
473
+ }
474
+ // A namespace ref means every recipe in it, exactly as the walk expanded it.
475
+ const targets =
476
+ parsed.recipe === undefined
477
+ ? [...resolved.keys()].filter((entry) =>
478
+ entry.startsWith(`${parsed.namespace}/`)
479
+ )
480
+ : [dependencyRefKey(parsed)];
481
+ for (const target of targets) {
482
+ if (!resolved.has(target)) continue;
483
+ if (declare(key, target, kind)) queue.push(target);
484
+ }
485
+ }
486
+ }
487
+ }
488
+
489
+ for (const [key, recipe] of [...resolved]) {
490
+ const reached = holders.get(key);
491
+ if (reached === undefined) {
492
+ resolved.delete(key);
493
+ continue;
494
+ }
495
+ recipe.requestedBy = [...reached.keys()];
496
+ recipe.ranges = recipe.ranges.filter((entry) => reached.has(entry.requestedBy));
497
+ recipe.kind = [...reached.values()].includes("subscribes") ? "subscribes" : "depends";
498
+ }
499
+ }
500
+
501
+ /** Records one more reason a repository is needed. */
502
+ function recordMissingRepo(into: Map<string, MissingRepo>, missing: MissingRepo): void {
503
+ const existing = into.get(missing.name);
504
+ if (existing === undefined) {
505
+ into.set(missing.name, missing);
506
+ return;
507
+ }
508
+ for (const entry of missing.requiredBy) {
509
+ const duplicate = existing.requiredBy.some(
510
+ (known) => known.ref === entry.ref && known.requestedBy === entry.requestedBy
511
+ );
512
+ if (!duplicate) existing.requiredBy.push(entry);
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Expands a namespace ref into one request per recipe in that namespace. A
518
+ * namespace subscription means "everything in here, including whatever is added
519
+ * later", so the expansion happens fresh on every resolution.
520
+ *
521
+ * @param item - The namespace request.
522
+ * @param context - The cached indexes and added repositories.
523
+ */
524
+ function expandNamespace(item: WorkItem, context: ResolveContext): WorkItem[] {
525
+ const { namespace } = item.ref;
526
+ const qualifier = item.ref.repo;
527
+ const expanded: WorkItem[] = [];
528
+ const seen = new Set<string>();
529
+ let namespaceFound = false;
530
+
531
+ for (const [repoName, index] of context.indexes) {
532
+ if (qualifier !== undefined && repoName !== qualifier) continue;
533
+ if (!Object.hasOwn(index.namespaces, namespace)) continue;
534
+ namespaceFound = true;
535
+
536
+ for (const key of Object.keys(index.recipes)) {
537
+ if (!key.startsWith(`${namespace}/`)) continue;
538
+ if (seen.has(key)) continue;
539
+ seen.add(key);
540
+ expanded.push({
541
+ ref: {
542
+ namespace,
543
+ recipe: key.slice(namespace.length + 1),
544
+ ...(qualifier === undefined ? {} : { repo: qualifier }),
545
+ // A namespace ref cannot carry a range when it is WRITTEN, but a
546
+ // namespace subscription entry can carry one, and a caller builds the
547
+ // request from that entry. When it does, the range applies to every
548
+ // recipe in the namespace; that is how the built-in `core`
549
+ // subscription stays pinned to the running sous version.
550
+ ...(item.ref.range === undefined ? {} : { range: item.ref.range }),
551
+ },
552
+ requestedBy: item.requestedBy,
553
+ kind: item.kind,
554
+ ...(item.prerelease === undefined ? {} : { prerelease: item.prerelease }),
555
+ });
556
+ }
557
+ }
558
+
559
+ if (!namespaceFound) throw unknownRefError(item, context, "namespace");
560
+
561
+ return expanded;
562
+ }
563
+
564
+ /**
565
+ * Resolves one recipe ref against every added repository, merging it with
566
+ * anything already resolved for the same recipe key.
567
+ *
568
+ * @param item - The request being resolved.
569
+ * @param context - The cached indexes and added repositories.
570
+ * @param resolved - What is resolved so far, so ranges accumulate.
571
+ */
572
+ function resolveRecipeRef(
573
+ item: WorkItem,
574
+ context: ResolveContext,
575
+ resolved: Map<string, ResolvedRecipe>
576
+ ): ResolvedRecipe {
577
+ const key = refKey(item.ref);
578
+ const qualifier = item.ref.repo;
579
+
580
+ const candidates: Array<{ repo: string; index: IndexFile }> = [];
581
+ for (const [repoName, index] of context.indexes) {
582
+ if (qualifier !== undefined && repoName !== qualifier) continue;
583
+ if (Object.hasOwn(index.recipes, key)) candidates.push({ repo: repoName, index });
584
+ }
585
+
586
+ if (candidates.length === 0) throw unknownRefError(item, context, "recipe");
587
+ if (candidates.length > 1) throw ambiguousRefError(item, candidates.map((c) => c.repo));
588
+
589
+ const chosen = candidates[0]!;
590
+ const entry = chosen.index.recipes[key]!;
591
+ const previous = resolved.get(key);
592
+
593
+ if (previous !== undefined && previous.repo !== chosen.repo) {
594
+ throw new ConfigError(
595
+ `The recipe '${key}' is being taken from two different repositories at once: ` +
596
+ `'${previous.repo}' and '${chosen.repo}'.\n` +
597
+ ` Qualify the refs that ask for it, so each one says which repository it means.`
598
+ );
599
+ }
600
+
601
+ const ranges = [...(previous?.ranges ?? [])];
602
+ const range = item.ref.range ?? "*";
603
+ if (!ranges.some((known) => known.range === range && known.requestedBy === item.requestedBy)) {
604
+ ranges.push({ range, requestedBy: item.requestedBy });
605
+ }
606
+
607
+ const prerelease =
608
+ (previous?.prerelease ?? false) ||
609
+ (item.prerelease ?? context.prerelease ?? false);
610
+
611
+ const version = pickVersion(key, entry, ranges, prerelease);
612
+ const versionEntry = entry.versions[version]!;
613
+
614
+ const requestedBy = [...(previous?.requestedBy ?? [])];
615
+ if (!requestedBy.includes(item.requestedBy)) requestedBy.push(item.requestedBy);
616
+
617
+ const namespace = key.slice(0, key.indexOf("/"));
618
+ return {
619
+ key,
620
+ namespace,
621
+ name: key.slice(namespace.length + 1),
622
+ repo: chosen.repo,
623
+ identity: context.repos[chosen.repo]?.identity ?? chosen.repo,
624
+ version,
625
+ hash: versionEntry.hash,
626
+ tag: versionEntry.tag,
627
+ path: entry.path,
628
+ ...(versionEntry.dependencies === undefined
629
+ ? {}
630
+ : { dependencies: versionEntry.dependencies }),
631
+ // A recipe held as a co-subscription by anyone is a co-subscription; a
632
+ // build dependency only stays one while nothing subscribes to it.
633
+ kind: previous?.kind === "subscribes" || item.kind === "subscribes" ? "subscribes" : "depends",
634
+ requestedBy,
635
+ ranges,
636
+ prerelease,
637
+ };
638
+ }
639
+
640
+ /**
641
+ * Picks the highest published version satisfying every range at once.
642
+ * Prereleases stay out of the match unless they were opted into.
643
+ *
644
+ * @param key - The recipe key, for error messages.
645
+ * @param entry - The recipe's index entry.
646
+ * @param ranges - Every range that has to hold, with who asked for it.
647
+ * @param prerelease - Whether prereleases may match.
648
+ */
649
+ function pickVersion(
650
+ key: string,
651
+ entry: IndexFile["recipes"][string],
652
+ ranges: Array<{ range: string; requestedBy: string }>,
653
+ prerelease: boolean
654
+ ): string {
655
+ const published = Object.keys(entry.versions);
656
+ const eligible = prerelease
657
+ ? published
658
+ : published.filter((version) => entry.versions[version]!.prerelease !== true);
659
+
660
+ let candidates = eligible;
661
+ for (const { range } of ranges) {
662
+ candidates = candidates.filter((version) =>
663
+ semver.satisfies(version, range, { includePrerelease: prerelease })
664
+ );
665
+ }
666
+
667
+ const best = semver.maxSatisfying(candidates, "*", { includePrerelease: prerelease });
668
+ if (best !== null) return best;
669
+
670
+ const asked = ranges
671
+ .map(({ range, requestedBy }) => ` ${range} (required by ${requestedBy})`)
672
+ .join("\n");
673
+ const available = sortVersions(published).join(", ");
674
+ const prereleaseNote = prerelease
675
+ ? ""
676
+ : "\n Prerelease versions were not considered. A subscription may opt into them " +
677
+ "with 'prerelease: true', and 'sous subscribe' with '--prerelease'.";
678
+
679
+ throw new ConfigError(
680
+ `No published version of '${key}' satisfies what was asked for.\n` +
681
+ ` Version ${ranges.length === 1 ? "range" : "ranges"} asked for:\n${asked}\n` +
682
+ ` Versions this repository publishes: ${available}.${prereleaseNote}`
683
+ );
684
+ }
685
+
686
+ /** Sorts version strings newest first, leaving anything unparsable at the end. */
687
+ function sortVersions(versions: string[]): string[] {
688
+ return [...versions].sort((left, right) => semver.rcompare(left, right, { loose: true }));
689
+ }
690
+
691
+ /**
692
+ * Builds the error for a ref that no added repository publishes. It names the
693
+ * repositories that were searched, because "not found" almost always means
694
+ * "the repository publishing it has not been added yet".
695
+ *
696
+ * @param item - The request that could not be resolved.
697
+ * @param context - The cached indexes and added repositories.
698
+ * @param what - Whether a namespace or a recipe was being looked for.
699
+ */
700
+ function unknownRefError(
701
+ item: WorkItem,
702
+ context: ResolveContext,
703
+ what: "namespace" | "recipe"
704
+ ): ConfigError {
705
+ const written = item.remote?.written ?? formatRef(item.ref);
706
+ const searched = [...context.indexes.keys()];
707
+ const where =
708
+ searched.length === 0
709
+ ? " This project has added no repositories yet."
710
+ : ` Repositories searched: ${searched.join(", ")}.`;
711
+ const asker =
712
+ item.requestedBy === PROJECT_REQUESTER
713
+ ? ""
714
+ : `\n It was required by the recipe '${item.requestedBy}'.`;
715
+
716
+ // When the ref said WHICH repository, the useful answer is what that
717
+ // repository does publish: a dependency that names something it has never
718
+ // heard of is almost always a typo or a recipe that was renamed.
719
+ const named = item.ref.repo;
720
+ const index = named === undefined ? undefined : context.indexes.get(named);
721
+ const publishes =
722
+ index === undefined
723
+ ? ""
724
+ : `\n The repository '${named}' publishes: ${
725
+ Object.keys(index.recipes).length === 0
726
+ ? "nothing yet"
727
+ : Object.keys(index.recipes).sort().join(", ")
728
+ }.`;
729
+
730
+ return new ConfigError(
731
+ `No added repository publishes the ${what} '${written}'.\n` +
732
+ `${where}${asker}${publishes}\n` +
733
+ ` Add the repository that publishes it with 'sous repo add <url>', then try again.`
734
+ );
735
+ }
736
+
737
+ /**
738
+ * Builds the error for a ref that resolves in more than one repository. Sous
739
+ * never picks a winner; it shows the qualified form for each repository and
740
+ * asks which one was meant.
741
+ *
742
+ * @param item - The ambiguous request.
743
+ * @param repos - The repositories that publish it.
744
+ */
745
+ function ambiguousRefError(item: WorkItem, repos: string[]): ConfigError {
746
+ const key = refKey(item.ref);
747
+ const range = item.ref.range === undefined ? "" : `@${item.ref.range}`;
748
+ const qualified = repos.map((repo) => ` ${repo}:${key}${range}`).join("\n");
749
+ const asker =
750
+ item.requestedBy === PROJECT_REQUESTER
751
+ ? ""
752
+ : ` It was required by the recipe '${item.requestedBy}'.`;
753
+
754
+ return new ConfigError(
755
+ `The ref '${key}' is published by more than one added repository: ${repos.join(", ")}.` +
756
+ `${asker}\n` +
757
+ ` Say which repository you mean by qualifying the ref with its name:\n${qualified}`
758
+ );
759
+ }
760
+
761
+ /**
762
+ * Finds the chain of recipes leading from a recipe back to itself, or undefined
763
+ * when there is none. A cycle is reported rather than treated as an error: the
764
+ * closure is still finite, and two recipes that co-subscribe to each other are
765
+ * unusual but not broken.
766
+ *
767
+ * @param key - The recipe reached a second time.
768
+ * @param from - The recipe that reached it.
769
+ * @param parents - Who pulled each recipe in.
770
+ */
771
+ function findCycle(
772
+ key: string,
773
+ from: string,
774
+ parents: Map<string, string>
775
+ ): string[] | undefined {
776
+ if (from === PROJECT_REQUESTER) return undefined;
777
+
778
+ const chain = [key];
779
+ let current: string | undefined = from;
780
+ const guard = new Set<string>();
781
+ while (current !== undefined && current !== PROJECT_REQUESTER) {
782
+ chain.push(current);
783
+ if (current === key) return chain.reverse();
784
+ if (guard.has(current)) return undefined;
785
+ guard.add(current);
786
+ current = parents.get(current);
787
+ }
788
+ return undefined;
789
+ }