@sous-io/sous 0.2.37 → 1.0.2

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 (244) hide show
  1. package/README.md +14 -256
  2. package/dist/bin.js +11 -0
  3. package/dist/sous.js +1094 -0
  4. package/docs/markdown/README.md +5 -4
  5. package/docs/markdown/_sidebar.md +21 -1
  6. package/docs/markdown/commands.md +294 -431
  7. package/docs/markdown/config-discovery.md +25 -14
  8. package/docs/markdown/config-inspection.md +53 -31
  9. package/docs/markdown/config-layers.md +168 -66
  10. package/docs/markdown/config-variables.md +44 -11
  11. package/docs/markdown/configuration.md +104 -17
  12. package/docs/markdown/design-principles.md +4 -1
  13. package/docs/markdown/repositories-authoring.md +30 -0
  14. package/docs/markdown/repositories-consuming.md +72 -14
  15. package/docs/markdown/repositories-file-formats.md +31 -13
  16. package/docs/markdown/repositories-providers.md +48 -57
  17. package/docs/markdown/repositories-quickstart.md +3 -2
  18. package/docs/markdown/repositories-troubleshooting.md +27 -1
  19. package/docs/markdown/repositories-variables.md +6 -1
  20. package/docs/markdown/repositories.md +18 -8
  21. package/package.json +48 -107
  22. package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +4 -6
  23. package/recipes/core/sous-skills/skills/about-liquid-templates/references/liquid-filters.tpl.md +2 -0
  24. package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +10 -9
  25. package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +35 -14
  26. package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +51 -20
  27. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  28. package/sous.config.schema.json +351 -132
  29. package/bin/run.js +0 -51
  30. package/src/base-command.ts +0 -457
  31. package/src/commands/build.ts +0 -181
  32. package/src/commands/clear.ts +0 -79
  33. package/src/commands/compile.ts +0 -155
  34. package/src/commands/config/get.ts +0 -110
  35. package/src/commands/config/show.ts +0 -32
  36. package/src/commands/config/validate.ts +0 -53
  37. package/src/commands/help.ts +0 -46
  38. package/src/commands/init.ts +0 -289
  39. package/src/commands/launch.ts +0 -139
  40. package/src/commands/lock/rebuild.ts +0 -239
  41. package/src/commands/lock/show.ts +0 -115
  42. package/src/commands/namespace/list.ts +0 -138
  43. package/src/commands/namespace/show.ts +0 -130
  44. package/src/commands/prune.ts +0 -43
  45. package/src/commands/recipe/list.ts +0 -112
  46. package/src/commands/recipe/show.ts +0 -439
  47. package/src/commands/repo/add.ts +0 -145
  48. package/src/commands/repo/contribute.ts +0 -604
  49. package/src/commands/repo/gc.ts +0 -173
  50. package/src/commands/repo/init.ts +0 -136
  51. package/src/commands/repo/link.ts +0 -899
  52. package/src/commands/repo/list.ts +0 -236
  53. package/src/commands/repo/release.ts +0 -685
  54. package/src/commands/repo/remove.ts +0 -194
  55. package/src/commands/repo/search.ts +0 -243
  56. package/src/commands/repo/submit.ts +0 -341
  57. package/src/commands/repo/unlink.ts +0 -461
  58. package/src/commands/subscription/add.ts +0 -286
  59. package/src/commands/subscription/list.ts +0 -208
  60. package/src/commands/subscription/remove.ts +0 -182
  61. package/src/commands/subscription/update.ts +0 -216
  62. package/src/commands/update.ts +0 -178
  63. package/src/commands/vars/ask.ts +0 -417
  64. package/src/commands/vars/index.ts +0 -79
  65. package/src/commands/vars/list.ts +0 -67
  66. package/src/commands/vars/show.ts +0 -77
  67. package/src/config-command.ts +0 -30
  68. package/src/lib/build-preparation.ts +0 -128
  69. package/src/lib/build-service.ts +0 -580
  70. package/src/lib/config-discovery.ts +0 -378
  71. package/src/lib/config-inspect.ts +0 -145
  72. package/src/lib/config-kernel.mjs +0 -379
  73. package/src/lib/config-schema.ts +0 -401
  74. package/src/lib/env-file.ts +0 -328
  75. package/src/lib/env-local.ts +0 -212
  76. package/src/lib/errors.ts +0 -32
  77. package/src/lib/glob-pattern.ts +0 -24
  78. package/src/lib/include-resolver.ts +0 -523
  79. package/src/lib/interactive.ts +0 -165
  80. package/src/lib/markdown-compiler.ts +0 -947
  81. package/src/lib/package-info.ts +0 -25
  82. package/src/lib/pid-service.ts +0 -99
  83. package/src/lib/project-install.d.mts +0 -44
  84. package/src/lib/project-install.mjs +0 -234
  85. package/src/lib/project-scaffold/index.ts +0 -332
  86. package/src/lib/project-scaffold/templates.ts +0 -240
  87. package/src/lib/repos/catalog-display.ts +0 -215
  88. package/src/lib/repos/catalog-inputs.ts +0 -288
  89. package/src/lib/repos/catalog.ts +0 -842
  90. package/src/lib/repos/contribute.ts +0 -452
  91. package/src/lib/repos/core-recipe.ts +0 -105
  92. package/src/lib/repos/declarations.ts +0 -187
  93. package/src/lib/repos/defaults.ts +0 -175
  94. package/src/lib/repos/formats/common.ts +0 -440
  95. package/src/lib/repos/formats/index-file.ts +0 -253
  96. package/src/lib/repos/formats/links-map.ts +0 -98
  97. package/src/lib/repos/formats/lockfile.ts +0 -169
  98. package/src/lib/repos/formats/patterns.ts +0 -57
  99. package/src/lib/repos/formats/recipe-manifest.ts +0 -205
  100. package/src/lib/repos/formats/repo-manifest.ts +0 -97
  101. package/src/lib/repos/formats/store-entry.ts +0 -84
  102. package/src/lib/repos/formats/variable-definition.ts +0 -268
  103. package/src/lib/repos/freshness.ts +0 -264
  104. package/src/lib/repos/git-clone.ts +0 -850
  105. package/src/lib/repos/identity.ts +0 -89
  106. package/src/lib/repos/index.ts +0 -57
  107. package/src/lib/repos/links.ts +0 -356
  108. package/src/lib/repos/load-manifest.ts +0 -237
  109. package/src/lib/repos/lock-service.ts +0 -454
  110. package/src/lib/repos/locked-namespace-resolver.ts +0 -232
  111. package/src/lib/repos/locked-recipe-lookup.ts +0 -123
  112. package/src/lib/repos/locked-recipes.ts +0 -254
  113. package/src/lib/repos/managed-layer.ts +0 -422
  114. package/src/lib/repos/memories-hash-name.ts +0 -33
  115. package/src/lib/repos/namespace-resolver.ts +0 -527
  116. package/src/lib/repos/providers/base.ts +0 -268
  117. package/src/lib/repos/providers/git.ts +0 -245
  118. package/src/lib/repos/providers/github.ts +0 -589
  119. package/src/lib/repos/providers/gitlab.ts +0 -332
  120. package/src/lib/repos/providers/http.ts +0 -128
  121. package/src/lib/repos/providers/index-cache.ts +0 -421
  122. package/src/lib/repos/providers/index.ts +0 -106
  123. package/src/lib/repos/providers/local.ts +0 -410
  124. package/src/lib/repos/providers/provider.ts +0 -615
  125. package/src/lib/repos/recipe-config-layers.ts +0 -316
  126. package/src/lib/repos/recipe-key-matcher.ts +0 -62
  127. package/src/lib/repos/recipe-memories.ts +0 -178
  128. package/src/lib/repos/recipe-targets.ts +0 -236
  129. package/src/lib/repos/ref-search.ts +0 -46
  130. package/src/lib/repos/release/bump.ts +0 -161
  131. package/src/lib/repos/release/changelog.ts +0 -461
  132. package/src/lib/repos/release/git-state.ts +0 -460
  133. package/src/lib/repos/release/index-builder.ts +0 -1108
  134. package/src/lib/repos/release/index.ts +0 -19
  135. package/src/lib/repos/release/plan.ts +0 -521
  136. package/src/lib/repos/release/settle.ts +0 -154
  137. package/src/lib/repos/release/submissions.ts +0 -214
  138. package/src/lib/repos/release/submit-checkout.ts +0 -290
  139. package/src/lib/repos/release/submit-questions.ts +0 -153
  140. package/src/lib/repos/release/submit-service.ts +0 -898
  141. package/src/lib/repos/release/tags.ts +0 -239
  142. package/src/lib/repos/release/validate.ts +0 -463
  143. package/src/lib/repos/resolver.ts +0 -1028
  144. package/src/lib/repos/scaffold/index.ts +0 -245
  145. package/src/lib/repos/scaffold/templates.ts +0 -517
  146. package/src/lib/repos/seed.ts +0 -473
  147. package/src/lib/repos/store/contract.ts +0 -64
  148. package/src/lib/repos/store/hash.ts +0 -191
  149. package/src/lib/repos/store/recipe-store.ts +0 -599
  150. package/src/lib/repos/store/settings.ts +0 -58
  151. package/src/lib/repos/subscription-service.ts +0 -3859
  152. package/src/lib/repos/trust.ts +0 -447
  153. package/src/lib/repos/update-plan.ts +0 -235
  154. package/src/lib/self-update/index.ts +0 -14
  155. package/src/lib/self-update/installs.ts +0 -413
  156. package/src/lib/self-update/managers.ts +0 -126
  157. package/src/lib/self-update/plan.ts +0 -315
  158. package/src/lib/self-update/registry.ts +0 -99
  159. package/src/lib/self-update/run.ts +0 -506
  160. package/src/lib/self-update/versions.ts +0 -243
  161. package/src/lib/settings.ts +0 -1107
  162. package/src/lib/sous-home.ts +0 -104
  163. package/src/lib/state.ts +0 -167
  164. package/src/lib/vars/answers.ts +0 -192
  165. package/src/lib/vars/ask.ts +0 -1152
  166. package/src/lib/vars/definition-source.ts +0 -261
  167. package/src/lib/vars/display.ts +0 -231
  168. package/src/lib/vars/index.ts +0 -20
  169. package/src/lib/vars/ladder.ts +0 -282
  170. package/src/lib/vars/mappings.ts +0 -260
  171. package/src/lib/vars/names.ts +0 -94
  172. package/src/lib/vars/preanswers.ts +0 -395
  173. package/src/lib/vars/question-plan.ts +0 -241
  174. package/src/lib/vars/report.ts +0 -228
  175. package/src/lib/vars/safe-regex.ts +0 -235
  176. package/src/lib/vars/secrets.ts +0 -131
  177. package/src/lib/vars/validate.ts +0 -312
  178. package/src/lib/version-report.ts +0 -90
  179. package/src/lib/watch-loop.ts +0 -148
  180. package/src/lib/watch-service.ts +0 -116
  181. package/src/services/ref-resolver/container.ts +0 -107
  182. package/src/services/ref-resolver/dependency.ts +0 -60
  183. package/src/services/ref-resolver/format.ts +0 -197
  184. package/src/services/ref-resolver/glob.ts +0 -115
  185. package/src/services/ref-resolver/hash-names.ts +0 -155
  186. package/src/services/ref-resolver/index.ts +0 -55
  187. package/src/services/ref-resolver/injectable.ts +0 -36
  188. package/src/services/ref-resolver/location.ts +0 -62
  189. package/src/services/ref-resolver/lookups/cached-index-lookup.ts +0 -53
  190. package/src/services/ref-resolver/lookups/catalog-lookup.ts +0 -23
  191. package/src/services/ref-resolver/lookups/catalog-matcher.ts +0 -261
  192. package/src/services/ref-resolver/lookups/chained-lookup.ts +0 -27
  193. package/src/services/ref-resolver/lookups/env-var-lookup.ts +0 -65
  194. package/src/services/ref-resolver/lookups/fetched-index-lookup.ts +0 -150
  195. package/src/services/ref-resolver/lookups/recorded-dependency-lookup.ts +0 -62
  196. package/src/services/ref-resolver/lookups/ref-lookup.ts +0 -54
  197. package/src/services/ref-resolver/lookups/variable-lookup.ts +0 -123
  198. package/src/services/ref-resolver/named-refs.ts +0 -144
  199. package/src/services/ref-resolver/parser/env-var-splitter.ts +0 -27
  200. package/src/services/ref-resolver/parser/location-splitter.ts +0 -239
  201. package/src/services/ref-resolver/parser/name-path-splitter.ts +0 -200
  202. package/src/services/ref-resolver/parser/partial-ref.ts +0 -84
  203. package/src/services/ref-resolver/parser/query-splitter.ts +0 -61
  204. package/src/services/ref-resolver/parser/range-splitter.ts +0 -52
  205. package/src/services/ref-resolver/parser/ref-parser.ts +0 -115
  206. package/src/services/ref-resolver/parser/repo-qualifier-splitter.ts +0 -51
  207. package/src/services/ref-resolver/parts.ts +0 -115
  208. package/src/services/ref-resolver/pruners/command-line-pruner.ts +0 -47
  209. package/src/services/ref-resolver/pruners/include-pruner.ts +0 -66
  210. package/src/services/ref-resolver/pruners/manifest-pruner.ts +0 -130
  211. package/src/services/ref-resolver/pruners/ref-pruner.ts +0 -135
  212. package/src/services/ref-resolver/pruners/rule-helpers.ts +0 -44
  213. package/src/services/ref-resolver/pruners/stored-key-pruner.ts +0 -136
  214. package/src/services/ref-resolver/ref-helpers.ts +0 -62
  215. package/src/services/ref-resolver/ref-picker-service.ts +0 -204
  216. package/src/services/ref-resolver/ref-report.ts +0 -84
  217. package/src/services/ref-resolver/ref-resolve-arguments.ts +0 -47
  218. package/src/services/ref-resolver/ref-resolve-result.ts +0 -36
  219. package/src/services/ref-resolver/ref-resolver-service.ts +0 -323
  220. package/src/services/ref-resolver/source.ts +0 -48
  221. package/src/services/ref-resolver/tokens.ts +0 -25
  222. package/src/services/ref-resolver/types.ts +0 -155
  223. package/src/templating/filters/bullet-list.ts +0 -9
  224. package/src/templating/filters/index.ts +0 -8
  225. package/src/templating/init-liquid-engine.ts +0 -139
  226. package/src/templating/lib/glob-files.ts +0 -74
  227. package/src/templating/lib/import-export.ts +0 -32
  228. package/src/templating/lib/secret-scope.ts +0 -41
  229. package/src/templating/lib/tag-args.ts +0 -19
  230. package/src/templating/tags/exportScalarVarsJs.ts +0 -46
  231. package/src/templating/tags/getFiles.ts +0 -89
  232. package/src/templating/tags/index.ts +0 -14
  233. package/src/templating/tags/listFiles.ts +0 -54
  234. package/src/templating/tags/showVars.ts +0 -28
  235. package/src/utils/choice-prompt.ts +0 -143
  236. package/src/utils/command-errors.ts +0 -186
  237. package/src/utils/command-help.ts +0 -45
  238. package/src/utils/confirm-prompt.ts +0 -110
  239. package/src/utils/flags.ts +0 -177
  240. package/src/utils/formatting.ts +0 -849
  241. package/src/utils/prompts.ts +0 -53
  242. package/src/utils/sous-directory.ts +0 -245
  243. package/src/utils/table.ts +0 -603
  244. package/src/utils/value-prompt.ts +0 -119
@@ -1,1108 +0,0 @@
1
- /**
2
- * Regenerating a repository's `sous.index.json` from what it actually publishes.
3
- *
4
- * The index is the portable contract every provider hands back, so it must
5
- * describe published reality and nothing else. Three rules follow from that,
6
- * and this module exists to keep them:
7
- *
8
- * 1. A published version is IMMUTABLE. An entry that is already in the index
9
- * and carries a tag keeps the hash and the dependencies it was published
10
- * with; a recomputed hash that disagrees, or dependencies that no longer
11
- * agree with what the version's manifest declares, are reported as errors,
12
- * never quietly written over.
13
- * 2. A version is published when a tag carries it, and every version the index
14
- * records has to have one. The single exception is the version a recipe's
15
- * manifest declares right now: a release regenerates the index, commits it
16
- * alongside the manifests, and tags that commit, so for the length of that
17
- * one commit the index describes a version whose tag is about to exist. Any
18
- * OLDER version without a tag is an error, because that is a published
19
- * version nothing can fetch.
20
- * 3. The tags are the backstop. A version that has a tag but is missing from the
21
- * index is rebuilt from the tagged tree, dependencies included, so deleting
22
- * the index file and regenerating it restores the same catalog.
23
- *
24
- * Each version entry also records what it was released against, so a consumer
25
- * installs the versions a recipe was published with rather than re-resolving
26
- * its ranges long afterwards. Those dependencies are resolved exactly once: when
27
- * the version is first recorded, against the repository as it stands then (or,
28
- * for a version rebuilt from its tag, as it stood at that tag), and never again.
29
- *
30
- * A version recorded here is also DESCRIBED, so a consumer can say what
31
- * subscribing to it brings in and asks before anything is fetched: each
32
- * dependency carries the manifest entry that declared it and whether it is a
33
- * co-subscription or a build dependency, and the version carries the variable
34
- * definitions its manifest publishes. Those fields are frozen with the rest of
35
- * the entry; a version published before they existed keeps its entry as it is.
36
- */
37
-
38
- import fs from "node:fs";
39
- import path from "node:path";
40
- import semver from "semver";
41
- import { INDEX_FILENAME } from "../formats/common.js";
42
- import {
43
- parseIndexFile,
44
- stringifyIndexFile,
45
- type IndexDependency,
46
- type IndexFile,
47
- type IndexRecipe,
48
- type IndexVersion,
49
- } from "../formats/index-file.js";
50
- import { parseRecipeManifest } from "../formats/recipe-manifest.js";
51
- import { loadJsonFile, parseYamlText, parseJsoncText } from "../load-manifest.js";
52
- import { hashDirectory } from "../store/hash.js";
53
- import type { RunOptions } from "../providers/git.js";
54
- import {
55
- listRecipeTags,
56
- readFileAtTag,
57
- recipeTagKey,
58
- tagCommitDate,
59
- tagFor,
60
- withTaggedTree,
61
- type RecipeTag,
62
- } from "./tags.js";
63
- import {
64
- describeError,
65
- validateRepo,
66
- type RepoValidation,
67
- type ValidatedRecipe,
68
- type ValidationProblem,
69
- } from "./validate.js";
70
- import {
71
- describeDependencyKind,
72
- foldDeclaration,
73
- type CoveringEntry,
74
- type DependencyDeclaration,
75
- type GatheredDeclaration,
76
- } from "../declarations.js";
77
- import type { DependencyKind } from "../formats/common.js";
78
- import {
79
- RefSource,
80
- isNamedRef,
81
- locationOf,
82
- namespaceOfKey,
83
- rangeOf,
84
- sharedRefResolver,
85
- shortKey,
86
- } from "../../../services/ref-resolver/index.js";
87
- import type { SettledDependency } from "./settle.js";
88
-
89
- /** A version that is ready to publish but has no tag yet. */
90
- export type PendingRelease = {
91
- /** The recipe's key, `namespace/recipe`. */
92
- key: string;
93
- /** The recipe folder, relative to the repository root. */
94
- path: string;
95
- /** The version its manifest declares. */
96
- version: string;
97
- /** The tag that would carry it. */
98
- tag: string;
99
- /** The content hash of the recipe folder as it stands in the working tree. */
100
- hash: string;
101
- };
102
-
103
- /** What one regeneration produced. */
104
- export type IndexBuildResult = {
105
- /** The regenerated index, ready to be written. */
106
- index: IndexFile;
107
- /** The index exactly as it would be written, so a caller can compare or save it. */
108
- text: string;
109
- /** True when the committed index differs from the regenerated one. */
110
- stale: boolean;
111
- /** Versions with no tag yet, in the order the repo manifest lists their recipes. */
112
- pending: PendingRelease[];
113
- /** Everything the regeneration found wrong, errors and warnings together. */
114
- problems: ValidationProblem[];
115
- /** Every recipe release tag the repository carries. */
116
- tags: RecipeTag[];
117
- };
118
-
119
- /** What `buildIndex` needs to know. */
120
- export type BuildIndexOptions = {
121
- /** The validated repository, from `validateRepo`. */
122
- validation: RepoValidation;
123
- /** The committed index, when there is a readable one. */
124
- existing?: IndexFile;
125
- /** The version of sous doing the regeneration, recorded as the generator. */
126
- sousVersion: string;
127
- /** When the regeneration ran. Defaults to now. */
128
- now?: Date;
129
- /** Every release tag, when the caller has already listed them. */
130
- tags?: RecipeTag[];
131
- /** The command runner git calls go through. */
132
- run?: RunOptions["run"];
133
- /**
134
- * The versions this run is about to publish, keyed `namespace/recipe`. Their
135
- * tags do not exist yet: the index is regenerated, committed alongside the
136
- * manifests, and only then tagged, so the tagged commit already carries an
137
- * index that describes itself.
138
- */
139
- publishing?: Record<string, string>;
140
- /**
141
- * The dependencies that read more than one way, as `settleDependencyLocations`
142
- * settled them, keyed by the dependency as written. Each is recorded under
143
- * the keys it reached, with the repository it settled on.
144
- */
145
- settled?: Map<string, SettledDependency>;
146
- };
147
-
148
- /** The index file's absolute path in a repository. */
149
- export function indexFilePath(rootDir: string): string {
150
- return path.join(rootDir, INDEX_FILENAME);
151
- }
152
-
153
- /**
154
- * Reads and validates the committed index, or returns undefined when there is
155
- * none. A file that is present but unreadable raises a ConfigError; the caller
156
- * decides whether to fail or to regenerate from the tags instead.
157
- *
158
- * @param rootDir - The repository's root directory.
159
- */
160
- export function readIndexFile(rootDir: string): IndexFile | undefined {
161
- const filePath = indexFilePath(rootDir);
162
- if (!fs.existsSync(filePath)) return undefined;
163
- return parseIndexFile(loadJsonFile(filePath, "repository index"), filePath);
164
- }
165
-
166
- /**
167
- * Regenerates a repository's index from its recipe manifests and its tags.
168
- *
169
- * @param options - The validated repository and everything the regeneration needs.
170
- */
171
- export async function buildIndex(
172
- options: BuildIndexOptions
173
- ): Promise<IndexBuildResult> {
174
- const { validation, existing, sousVersion } = options;
175
- const rootDir = validation.rootDir;
176
- const now = options.now ?? new Date();
177
- const run = options.run;
178
-
179
- const tags = options.tags ?? (await listRecipeTags(rootDir, { run }));
180
- const tagsByKey = groupTagsByKey(tags);
181
- const problems: ValidationProblem[] = [];
182
- const pending: PendingRelease[] = [];
183
-
184
- const namespaces: IndexFile["namespaces"] = {};
185
- for (const [name, declaration] of Object.entries(validation.manifest.namespaces)) {
186
- namespaces[name] =
187
- declaration.description === undefined
188
- ? {}
189
- : { description: declaration.description };
190
- }
191
-
192
- const recipes: IndexFile["recipes"] = {};
193
-
194
- // What each recipe in this repository is published at once this run is done,
195
- // and every version each of them publishes. Both are needed to turn a
196
- // dependency's declared range into the exact version a consumer installs.
197
- const publishing = options.publishing ?? {};
198
- const current = siblingState(validation, tagsByKey, existing, publishing);
199
-
200
- for (const recipe of validation.recipes) {
201
- if (!Object.hasOwn(namespaces, recipe.manifest.namespace)) continue;
202
-
203
- const key = recipe.key;
204
- const existingVersions = existing?.recipes[key]?.versions ?? {};
205
- const versions: Record<string, IndexVersion> = { ...existingVersions };
206
- const recipeTags = tagsByKey.get(key) ?? [];
207
- const manifestName = path.basename(recipe.manifestPath);
208
-
209
- // Rule 3: every tagged version belongs in the index, including ones the
210
- // committed index has lost. Its dependencies are resolved against the
211
- // repository as it stood at the tag, which is what it was released against.
212
- for (const tag of recipeTags) {
213
- if (Object.hasOwn(versions, tag.version)) continue;
214
- const rebuilt = await rebuildTaggedVersion(
215
- rootDir,
216
- tag,
217
- recipe,
218
- tagsByKey,
219
- existing,
220
- run,
221
- options.settled
222
- );
223
- problems.push(...rebuilt.problems);
224
- versions[tag.version] = {
225
- hash: rebuilt.hash,
226
- tag: tag.tag,
227
- prerelease: semver.prerelease(tag.version) !== null,
228
- ...(await releasedAtOf(rootDir, tag.tag, run)),
229
- ...rebuilt.description,
230
- };
231
- }
232
-
233
- const version = recipe.manifest.version;
234
- const tagName = tagFor(recipe.manifest.namespace, recipe.manifest.name, version);
235
- const hasTag = recipeTags.some((entry) => entry.tag === tagName);
236
- const workingHash = await hashDirectory(recipe.dir);
237
- const where = relativeTo(rootDir, recipe.manifestPath);
238
- const declared = declaredDependencies(recipe, validation, options.settled);
239
-
240
- // A version the index publishes must have a tag, with one exception: the
241
- // version the manifest declares right now, which is the one a release is in
242
- // the middle of publishing. Everything older is history, and history with a
243
- // missing tag is a published version nothing can fetch.
244
- for (const [known, published] of Object.entries(existingVersions)) {
245
- if (known === version) continue;
246
- if (recipeTags.some((entry) => entry.version === known)) continue;
247
- problems.push({
248
- level: "error",
249
- where: `${INDEX_FILENAME} recipes['${key}'].versions['${known}']`,
250
- message:
251
- `the index publishes version ${known}, but the tag '${published.tag}' does not ` +
252
- `exist in this repository. A missing tag must never hide a published version; ` +
253
- `restore the tag, or remove the version from the index.`,
254
- });
255
- }
256
-
257
- if (hasTag) {
258
- problems.push(
259
- ...(await checkTaggedMetadata(rootDir, tagName, recipe.path, manifestName, version, where, run))
260
- );
261
-
262
- const taggedHash = await hashTaggedRecipe(rootDir, tagName, recipe.path, run);
263
- const published = existingVersions[version];
264
-
265
- if (published !== undefined && published.hash !== taggedHash) {
266
- problems.push({
267
- level: "error",
268
- where: `${INDEX_FILENAME} recipes['${key}'].versions['${version}']`,
269
- message:
270
- `the index records a different content hash from the one the tag '${tagName}' ` +
271
- `now carries. A published version never changes, so either the tag was moved ` +
272
- `or the index was edited; restore whichever is wrong before releasing again.`,
273
- });
274
- } else if (published !== undefined) {
275
- // Already published: the entry is carried forward as it stands, its
276
- // dependencies and any field a later sous wrote included. Only a missing
277
- // release date is filled in, from the tag.
278
- if (published.releasedAt === undefined) {
279
- versions[version] = { ...published, ...(await releasedAtOf(rootDir, tagName, run)) };
280
- }
281
- // The check needs the manifest that was published, which the working one
282
- // is only while the folder still matches the tag. An entry with no
283
- // dependencies at all was published before sous recorded them; a
284
- // consumer resolves that version's ranges, and it stays that way.
285
- if (taggedHash === workingHash && published.dependencies !== undefined) {
286
- problems.push(
287
- ...checkRecordedDependencies(key, version, published.dependencies, declared)
288
- );
289
- }
290
- if (taggedHash === workingHash) {
291
- problems.push(...checkRecordedLists(key, version, published, recipe));
292
- }
293
- }
294
- // A tagged version the index did not record was rebuilt from its tag above.
295
-
296
- if (taggedHash !== workingHash) {
297
- // A recipe this run is not publishing is allowed to have unpublished
298
- // changes sitting in it: that is what a scoped release leaves behind,
299
- // and it is worth saying rather than stopping for.
300
- const publishingThis =
301
- options.publishing === undefined || Object.hasOwn(publishing, key);
302
- problems.push({
303
- level: publishingThis ? "error" : "warning",
304
- where,
305
- message: publishingThis
306
- ? `version ${version} was already published as the tag '${tagName}', and the ` +
307
- `files in this folder no longer match what that tag carries. A published ` +
308
- `version never changes: bump the version in this manifest, or with ` +
309
- `'sous repo release --bump patch'.`
310
- : `version ${version} is published as the tag '${tagName}', and this folder has ` +
311
- `changed since. Those changes are outside this release, so they stay ` +
312
- `unpublished; release '${key}' to publish them.`,
313
- });
314
- }
315
- } else {
316
- // No tag yet. The version is either one this run is about to cut, or one
317
- // a release commit already recorded and CI will cut after the merge; in
318
- // both cases the index describes it from the working tree, and the tag
319
- // follows on the very commit that carries this index.
320
- const published = existingVersions[version];
321
- if (options.publishing?.[key] === version || published !== undefined) {
322
- versions[version] = {
323
- hash: workingHash,
324
- tag: tagName,
325
- prerelease: semver.prerelease(version) !== null,
326
- releasedAt: published?.releasedAt ?? now.toISOString(),
327
- ...describeVersion(recipe, declared, current),
328
- };
329
- }
330
-
331
- pending.push({
332
- key,
333
- path: recipe.path,
334
- version,
335
- tag: tagName,
336
- hash: workingHash,
337
- });
338
- }
339
-
340
- if (Object.keys(versions).length > 0) {
341
- const entry: IndexRecipe = { path: recipe.path, versions };
342
- if (recipe.manifest.description !== undefined) {
343
- entry.description = recipe.manifest.description;
344
- }
345
- recipes[key] = entry;
346
- }
347
- }
348
-
349
- problems.push(...checkOrphanTags(validation, tags));
350
-
351
- const index: IndexFile = {
352
- formatVersion: 1,
353
- name: validation.manifest.name,
354
- generatedAt: now.toISOString(),
355
- generator: sousVersion,
356
- namespaces,
357
- recipes,
358
- };
359
-
360
- // The stamp changes on every run, so it is only allowed to change the file
361
- // when something else did too; otherwise `--check` would call a perfectly
362
- // current index stale every time it ran.
363
- if (existing !== undefined && sameExceptStamp(existing, index)) {
364
- index.generatedAt = existing.generatedAt;
365
- }
366
-
367
- const text = stringifyIndexFile(index);
368
- const stale = existing === undefined || stringifyIndexFile(existing) !== text;
369
-
370
- return { index, text, stale, pending, problems, tags };
371
- }
372
-
373
- /**
374
- * Says, in plain language, how a committed index differs from a regenerated
375
- * one. This is what `--check` prints when it refuses: an author needs to know
376
- * which recipe and which version is out of step, not that two files differ.
377
- *
378
- * @param existing - The committed index, or undefined when there is none.
379
- * @param rebuilt - The regenerated index.
380
- */
381
- export function describeIndexDrift(
382
- existing: IndexFile | undefined,
383
- rebuilt: IndexFile
384
- ): string[] {
385
- if (existing === undefined) {
386
- return [`there is no ${INDEX_FILENAME} in this repository yet`];
387
- }
388
-
389
- const lines: string[] = [];
390
-
391
- if (existing.name !== rebuilt.name) {
392
- lines.push(
393
- `the index calls this repository '${existing.name}', and its manifest calls it ` +
394
- `'${rebuilt.name}'`
395
- );
396
- }
397
- if (existing.generator !== rebuilt.generator) {
398
- lines.push(
399
- `the index was generated by sous ${existing.generator}, and this is sous ` +
400
- `${rebuilt.generator}`
401
- );
402
- }
403
-
404
- for (const name of Object.keys(rebuilt.namespaces)) {
405
- if (!Object.hasOwn(existing.namespaces, name)) {
406
- lines.push(`the namespace '${name}' is missing from the index`);
407
- }
408
- }
409
- for (const name of Object.keys(existing.namespaces)) {
410
- if (!Object.hasOwn(rebuilt.namespaces, name)) {
411
- lines.push(`the index still declares the namespace '${name}'`);
412
- }
413
- }
414
-
415
- for (const [key, recipe] of Object.entries(rebuilt.recipes)) {
416
- const before = existing.recipes[key];
417
- if (before === undefined) {
418
- lines.push(`the recipe '${key}' is missing from the index`);
419
- continue;
420
- }
421
- if (before.path !== recipe.path) {
422
- lines.push(
423
- `the recipe '${key}' is listed at '${before.path}' in the index and at ` +
424
- `'${recipe.path}' in the repository`
425
- );
426
- }
427
- if (before.description !== recipe.description) {
428
- lines.push(`the description of '${key}' has changed since the index was written`);
429
- }
430
- for (const version of Object.keys(recipe.versions)) {
431
- if (!Object.hasOwn(before.versions, version)) {
432
- lines.push(`version ${version} of '${key}' is missing from the index`);
433
- }
434
- }
435
- }
436
-
437
- for (const [key, recipe] of Object.entries(existing.recipes)) {
438
- if (!Object.hasOwn(rebuilt.recipes, key)) {
439
- lines.push(`the index still publishes '${key}', which this repository does not`);
440
- continue;
441
- }
442
- for (const version of Object.keys(recipe.versions)) {
443
- if (!Object.hasOwn(rebuilt.recipes[key]!.versions, version)) {
444
- lines.push(`the index still publishes version ${version} of '${key}'`);
445
- }
446
- }
447
- }
448
-
449
- if (lines.length === 0) {
450
- lines.push("the index differs from what this repository describes");
451
- }
452
- return lines;
453
- }
454
-
455
- // --- Internals ----------------------------------------------------------------------------------
456
-
457
- /**
458
- * One dependency a recipe's manifest declares, keyed `namespace/recipe` in
459
- * `DeclaredDependencies`. A whole-namespace declaration is expanded into one
460
- * entry per recipe the namespace holds.
461
- */
462
- type DeclaredLocation =
463
- | {
464
- /** A recipe in another repository, named by its location. */
465
- where: "remote";
466
- /** The canonical identity of the repository publishing it. */
467
- repo: string;
468
- /** The range the manifest declared, `*` when it declared none. */
469
- range: string;
470
- }
471
- | {
472
- /** A recipe in this same repository. */
473
- where: "sibling";
474
- /** The range the manifest declared, when it declared one. */
475
- range?: string;
476
- /**
477
- * True when the manifest names this recipe itself, rather than only the
478
- * namespace holding it. A recipe a namespace gained after a version was
479
- * published is rightly missing from that version's entry; one the
480
- * manifest names is not.
481
- */
482
- named: boolean;
483
- };
484
-
485
- /** One declared dependency: where it lives, and the manifest entry bringing it in. */
486
- type DeclaredDependency = DeclaredLocation & DependencyDeclaration;
487
-
488
- /** Everything one recipe's manifest declares, under `depends` and `subscribes`. */
489
- type DeclaredDependencies = {
490
- /** Every dependency, keyed `namespace/recipe`; a later declaration wins over an earlier one. */
491
- byKey: Map<string, DeclaredDependency>;
492
- /** The namespaces of this repository the manifest declares whole. */
493
- namespaces: Set<string>;
494
- };
495
-
496
- /** What each recipe of a repository is published at, as one resolution sees it. */
497
- type SiblingState = {
498
- /** The version each recipe is released at, which is what "released alongside me" means. */
499
- settled: Map<string, string>;
500
- /** Every version each recipe publishes, for resolving a declared range. */
501
- published: Map<string, string[]>;
502
- };
503
-
504
- /**
505
- * Reads the dependencies a recipe's manifest declares, expanding a
506
- * whole-namespace declaration into the recipes that namespace holds.
507
- *
508
- * @param recipe - The recipe whose dependencies are being read.
509
- * @param validation - The validated repository, for expanding namespace refs.
510
- * @param settled - The dependencies that read more than one way, as the release settled them.
511
- */
512
- function declaredDependencies(
513
- recipe: ValidatedRecipe,
514
- validation: RepoValidation,
515
- settled: Map<string, SettledDependency> = new Map()
516
- ): DeclaredDependencies {
517
- const byKey = new Map<string, DeclaredLocation>();
518
- const namespaces = new Set<string>();
519
- // Which entry brings each recipe in, and whether as a co-subscription, by the
520
- // rule every reader of the index shares (`foldDeclaration`).
521
- const declarations = new Map<string, GatheredDeclaration>();
522
- const declared: ReadonlyArray<readonly [DependencyKind, string]> = [
523
- ...(recipe.manifest.depends ?? []).map((written) => ["depends", written] as const),
524
- ...(recipe.manifest.subscribes ?? []).map((written) => ["subscribes", written] as const),
525
- ];
526
-
527
- /** Records that one entry covers one recipe. */
528
- const cover = (key: string, entry: CoveringEntry): void => {
529
- declarations.set(key, foldDeclaration(declarations.get(key), entry));
530
- };
531
-
532
- /** Records one sibling, remembering whether any declaration named it. */
533
- const addSibling = (key: string, range: string | undefined, named: boolean): void => {
534
- const before = byKey.get(key);
535
- const namedBefore = before?.where === "sibling" && before.named;
536
- byKey.set(key, {
537
- where: "sibling",
538
- ...(range === undefined ? {} : { range }),
539
- named: named || namedBefore,
540
- });
541
- };
542
-
543
- for (const [kind, written] of declared) {
544
- // A dependency that reads more than one way was settled by the release,
545
- // and what it settled on is what a consumer reads instead of probing.
546
- const answer = settled.get(written.trim());
547
- if (answer !== undefined) {
548
- for (const key of answer.keys) {
549
- byKey.set(key, { where: "remote", repo: answer.identity, range: answer.range ?? "*" });
550
- cover(key, { written, kind, named: answer.keys.length === 1 });
551
- }
552
- continue;
553
- }
554
-
555
- let readings;
556
- try {
557
- readings = sharedRefResolver().parse(written, RefSource.Manifest).refs;
558
- } catch {
559
- // A dependency that does not parse is already reported by validation.
560
- continue;
561
- }
562
- // Several readings, or a browser path, are settled above or reported by
563
- // the settling step; there is nothing more to record here.
564
- const parsed = readings[0]!;
565
- if (readings.length !== 1 || !isNamedRef(parsed)) continue;
566
-
567
- const location = locationOf(parsed);
568
- if (location !== undefined) {
569
- // A whole namespace in another repository is read from that repository's
570
- // own index by the consumer; only a recipe has a key to record.
571
- if (parsed.kind === "recipe") {
572
- byKey.set(shortKey(parsed), {
573
- where: "remote",
574
- repo: location.identity,
575
- range: rangeOf(parsed) ?? "*",
576
- });
577
- cover(shortKey(parsed), { written, kind, named: true });
578
- }
579
- continue;
580
- }
581
-
582
- if (parsed.kind === "recipe") {
583
- addSibling(shortKey(parsed), rangeOf(parsed), true);
584
- cover(shortKey(parsed), { written, kind, named: true });
585
- continue;
586
- }
587
-
588
- // A whole-namespace dependency means every recipe in that namespace.
589
- namespaces.add(parsed.name);
590
- for (const entry of validation.recipes) {
591
- if (entry.manifest.namespace !== parsed.name) continue;
592
- if (entry.key === recipe.key) continue;
593
- addSibling(entry.key, undefined, false);
594
- cover(entry.key, { written, kind, named: false });
595
- }
596
- }
597
-
598
- const described = new Map<string, DeclaredDependency>();
599
- for (const [key, dependency] of byKey) {
600
- const { declared: entry, kind } = declarations.get(key)!;
601
- described.set(key, { ...dependency, declared: entry, kind });
602
- }
603
-
604
- return { byKey: described, namespaces };
605
- }
606
-
607
- /**
608
- * Works out what each recipe of a repository is published at, for resolving
609
- * the dependencies of a version being recorded.
610
- *
611
- * Two moments call this. A run recording a NEW version resolves against the
612
- * repository as it stands, counting the versions the run itself is publishing.
613
- * A run rebuilding a version from its TAG resolves against the repository as
614
- * it stood at that tag (no `publishing` given): a sibling is settled on the
615
- * version its manifest declared there, and only versions up to that one count
616
- * as published.
617
- *
618
- * @param validation - The repository, as it stands or as it stood at a tag.
619
- * @param tagsByKey - Every release tag, grouped by recipe.
620
- * @param existing - The committed index, when there is one.
621
- * @param publishing - The versions this run publishes; omitted for a tag.
622
- */
623
- function siblingState(
624
- validation: RepoValidation,
625
- tagsByKey: Map<string, RecipeTag[]>,
626
- existing: IndexFile | undefined,
627
- publishing?: Record<string, string>
628
- ): SiblingState {
629
- const settled = new Map<string, string>();
630
- const published = new Map<string, string[]>();
631
-
632
- for (const recipe of validation.recipes) {
633
- const version = publishing?.[recipe.key] ?? recipe.manifest.version;
634
- settled.set(recipe.key, version);
635
-
636
- const known = new Set((tagsByKey.get(recipe.key) ?? []).map((tag) => tag.version));
637
- for (const other of Object.keys(existing?.recipes[recipe.key]?.versions ?? {})) {
638
- known.add(other);
639
- }
640
- known.add(version);
641
-
642
- const all = [...known];
643
- published.set(
644
- recipe.key,
645
- publishing === undefined ? all.filter((other) => semver.lte(other, version)) : all
646
- );
647
- }
648
-
649
- return { settled, published };
650
- }
651
-
652
- /**
653
- * Turns one recipe's declared dependencies into the exact versions its index
654
- * entry publishes.
655
- *
656
- * This is the whole point of recording dependencies in the index: a consumer
657
- * installing version 1.4.0 of a recipe installs what 1.4.0 was released
658
- * against, not whatever its ranges happen to reach months later. That is also
659
- * why this runs only when a version is first recorded; an entry the index
660
- * already publishes is carried forward and never resolved again.
661
- *
662
- * - A SIBLING with no range means "the version released alongside me", which
663
- * is the version that sibling is settled on.
664
- * - A SIBLING with a range resolves against the versions this repository
665
- * publishes.
666
- * - A CROSS-REPOSITORY dependency carries the identity of the repository it
667
- * lives in, which is what a consumer needs in order to add that repository
668
- * and find the recipe. Its exact version belongs to that repository's own
669
- * index, so the range the manifest declared is recorded instead.
670
- *
671
- * @param declared - What the recipe's manifest declares.
672
- * @param siblings - What each recipe here is published at, for this resolution.
673
- */
674
- function resolveIndexDependencies(
675
- declared: DeclaredDependencies,
676
- siblings: SiblingState
677
- ): Record<string, IndexDependency> {
678
- const resolved: Record<string, IndexDependency> = {};
679
-
680
- for (const [key, dependency] of declared.byKey) {
681
- const how = { declared: dependency.declared, kind: dependency.kind };
682
- if (dependency.where === "remote") {
683
- resolved[key] = { repo: dependency.repo, range: dependency.range, ...how };
684
- continue;
685
- }
686
- if (dependency.range === undefined) {
687
- const settled = siblings.settled.get(key);
688
- resolved[key] = {
689
- ...(settled === undefined ? { range: "*" } : { version: settled }),
690
- ...how,
691
- };
692
- continue;
693
- }
694
- const best = semver.maxSatisfying(siblings.published.get(key) ?? [], dependency.range);
695
- resolved[key] = {
696
- ...(best === null ? { range: dependency.range } : { version: best }),
697
- ...how,
698
- };
699
- }
700
-
701
- return resolved;
702
- }
703
-
704
- /**
705
- * Everything a version records about itself beyond its hash, tag and date: its
706
- * dependencies, resolved and each with the entry that declared it, its
707
- * manifest's `depends` and `subscribes` lists as written, and the variable
708
- * definitions its manifest publishes. All are recorded even when empty, so a
709
- * reader can tell "none" from "not recorded".
710
- *
711
- * @param recipe - The recipe, with the manifest of the version being recorded.
712
- * @param declared - What that manifest declares.
713
- * @param siblings - What each recipe here is published at, for this resolution.
714
- */
715
- function describeVersion(
716
- recipe: ValidatedRecipe,
717
- declared: DeclaredDependencies,
718
- siblings: SiblingState
719
- ): VersionDescription {
720
- return {
721
- dependencies: resolveIndexDependencies(declared, siblings),
722
- depends: (recipe.manifest.depends ?? []).map((written) => written.trim()),
723
- subscribes: (recipe.manifest.subscribes ?? []).map((written) => written.trim()),
724
- variables: recipe.manifest.variables ?? [],
725
- };
726
- }
727
-
728
- /** What `describeVersion` records about a version. */
729
- type VersionDescription = Pick<
730
- IndexVersion,
731
- "dependencies" | "depends" | "subscribes" | "variables"
732
- >;
733
-
734
- /**
735
- * Checks the manifest lists the index records for an already published
736
- * version against the version's manifest. They are copied, not resolved, so
737
- * any difference means the index was edited.
738
- *
739
- * @param key - The recipe's key, `namespace/recipe`.
740
- * @param version - The published version.
741
- * @param published - The version's index entry.
742
- * @param recipe - The recipe, with the version's manifest.
743
- */
744
- function checkRecordedLists(
745
- key: string,
746
- version: string,
747
- published: IndexVersion,
748
- recipe: ValidatedRecipe
749
- ): ValidationProblem[] {
750
- const differences: string[] = [];
751
- for (const list of ["depends", "subscribes"] as const) {
752
- const recorded = published[list];
753
- if (recorded === undefined) continue;
754
- const declared = (recipe.manifest[list] ?? []).map((written) => written.trim());
755
- if (JSON.stringify(recorded) === JSON.stringify(declared)) continue;
756
- differences.push(
757
- `'${list}': the index records [${recorded.join(", ")}], and the manifest declares ` +
758
- `[${declared.join(", ")}]`
759
- );
760
- }
761
-
762
- if (differences.length === 0) return [];
763
- return [
764
- {
765
- level: "error",
766
- where: `${INDEX_FILENAME} recipes['${key}'].versions['${version}']`,
767
- message:
768
- `version ${version} of '${key}' is already published, and the lists the index ` +
769
- `records for it disagree with its manifest:\n` +
770
- differences.map((line) => ` - ${line}\n`).join("") +
771
- ` A published version never changes. If the index was edited, restore the entry; ` +
772
- `to publish different lists, bump the version in this manifest, or with ` +
773
- `'sous repo release --bump patch'.`,
774
- },
775
- ];
776
- }
777
-
778
- /**
779
- * Checks the dependencies the index records for an already published version
780
- * against what that version's manifest declares.
781
- *
782
- * The recorded list is frozen, so it is never compared with a fresh
783
- * resolution: a sibling released since, or a recipe a declared namespace has
784
- * gained since, is exactly what freezing keeps out of it. What must still hold
785
- * is that the list honours the manifest: every recipe the manifest names is
786
- * there, nothing is there that the manifest does not declare, a sibling sits
787
- * inside its declared range, and a dependency in another repository carries the
788
- * repository and range the manifest wrote. A disagreement means the index was
789
- * edited, or this sous reads the manifest differently from the one that
790
- * published the version.
791
- *
792
- * @param key - The recipe's key, `namespace/recipe`.
793
- * @param version - The published version.
794
- * @param recorded - The dependencies the index records for it.
795
- * @param declared - What the version's manifest declares.
796
- */
797
- function checkRecordedDependencies(
798
- key: string,
799
- version: string,
800
- recorded: Record<string, IndexDependency> | undefined,
801
- declared: DeclaredDependencies
802
- ): ValidationProblem[] {
803
- const differences: string[] = [];
804
- const entries = recorded ?? {};
805
-
806
- for (const [name, dependency] of declared.byKey) {
807
- const entry = entries[name];
808
- if (entry === undefined) {
809
- if (dependency.where === "remote" || dependency.named) {
810
- differences.push(
811
- `'${name}': the manifest declares ${describeDeclared(dependency)}, and the index ` +
812
- `records nothing for it`
813
- );
814
- }
815
- continue;
816
- }
817
- if (!recordedSatisfies(entry, dependency)) {
818
- differences.push(
819
- `'${name}': the index records ${describeRecorded(entry)}, and the manifest declares ` +
820
- `${describeDeclared(dependency)}`
821
- );
822
- }
823
- }
824
-
825
- for (const [name, entry] of Object.entries(entries)) {
826
- if (declared.byKey.has(name)) continue;
827
- // A recipe the declared namespace held when the version was published, and
828
- // no longer does, is history rather than a disagreement.
829
- const namespace = namespaceOfKey(name);
830
- if (entry.repo === undefined && declared.namespaces.has(namespace)) continue;
831
- differences.push(
832
- `'${name}': the index records ${describeRecorded(entry)}, and the manifest does not ` +
833
- `declare it`
834
- );
835
- }
836
-
837
- if (differences.length === 0) return [];
838
- return [
839
- {
840
- level: "error",
841
- where: `${INDEX_FILENAME} recipes['${key}'].versions['${version}'].dependencies`,
842
- message:
843
- `version ${version} of '${key}' is already published, and the dependencies the index ` +
844
- `records for it disagree with what its manifest declares:\n` +
845
- differences.map((line) => ` - ${line}\n`).join("") +
846
- ` A published version never changes, so a release carries its dependencies forward ` +
847
- `exactly as they were published. If the index was edited, restore the entry. To ` +
848
- `publish different dependencies, bump the version in this manifest, or with ` +
849
- `'sous repo release --bump patch'.`,
850
- },
851
- ];
852
- }
853
-
854
- /** True when a recorded dependency honours what the manifest declares for it. */
855
- function recordedSatisfies(entry: IndexDependency, dependency: DeclaredDependency): boolean {
856
- // How it was declared is recorded only since sous described recipes in the
857
- // index; an entry that records it has to agree with the manifest too.
858
- if (entry.declared !== undefined && entry.declared !== dependency.declared) return false;
859
- if (entry.kind !== undefined && entry.kind !== dependency.kind) return false;
860
- if (dependency.where === "remote") {
861
- return (
862
- entry.repo === dependency.repo &&
863
- entry.range === dependency.range &&
864
- entry.version === undefined
865
- );
866
- }
867
- if (entry.repo !== undefined) return false;
868
- if (dependency.range === undefined) return true;
869
- if (entry.version !== undefined) return semver.satisfies(entry.version, dependency.range);
870
- return entry.range === dependency.range;
871
- }
872
-
873
- /** Describes a recorded dependency the way an author would say it. */
874
- function describeRecorded(entry: IndexDependency): string {
875
- const what =
876
- entry.version !== undefined ? `version ${entry.version}` : `the range '${entry.range}'`;
877
- const from = entry.repo === undefined ? what : `${what} from ${entry.repo}`;
878
- return entry.declared === undefined || entry.kind === undefined
879
- ? from
880
- : `${from}, through the entry '${entry.declared}' as a ${describeDependencyKind(entry.kind)}`;
881
- }
882
-
883
- /** Describes a declared dependency the way an author would say it. */
884
- function describeDeclared(dependency: DeclaredDependency): string {
885
- const how =
886
- `, through the entry '${dependency.declared}' as a ` +
887
- describeDependencyKind(dependency.kind);
888
- if (dependency.where === "remote") {
889
- return `the range '${dependency.range}' from ${dependency.repo}${how}`;
890
- }
891
- return dependency.range === undefined
892
- ? `it with no range${how}`
893
- : `the range '${dependency.range}'${how}`;
894
- }
895
-
896
- /** What rebuilding one tagged version from its tag produced. */
897
- type RebuiltVersion = {
898
- /** The content hash of the recipe folder at the tag. */
899
- hash: string;
900
- /**
901
- * Its dependencies and variable definitions when the recipe could be read at
902
- * the tag, and nothing when it could not; see `describeVersion`.
903
- */
904
- description: VersionDescription;
905
- /** A warning when they could not. */
906
- problems: ValidationProblem[];
907
- };
908
-
909
- /**
910
- * Rebuilds a tagged version the index does not record, from the tree its tag
911
- * carries: the content hash of the recipe folder, and the dependencies its
912
- * manifest declared there, resolved against the repository as it stood at the
913
- * tag. That is as close as sous can get to what the release that cut the tag
914
- * recorded; it is resolved once, then frozen like any other entry.
915
- *
916
- * @param rootDir - The repository's root directory.
917
- * @param tag - The tag to rebuild from.
918
- * @param recipe - The recipe as it stands now, for its folder and its key.
919
- * @param tagsByKey - Every release tag, grouped by recipe.
920
- * @param existing - The committed index, when there is one.
921
- * @param run - The command runner git calls go through.
922
- * @param settled - The dependencies that read more than one way, as the release settled them.
923
- */
924
- async function rebuildTaggedVersion(
925
- rootDir: string,
926
- tag: RecipeTag,
927
- recipe: ValidatedRecipe,
928
- tagsByKey: Map<string, RecipeTag[]>,
929
- existing: IndexFile | undefined,
930
- run: RunOptions["run"],
931
- settled?: Map<string, SettledDependency>
932
- ): Promise<RebuiltVersion> {
933
- return withTaggedTree(
934
- rootDir,
935
- tag.tag,
936
- ".",
937
- async (treeDir) => {
938
- const hash = await hashDirectory(path.join(treeDir, ...recipe.path.split("/")));
939
-
940
- let reason: string;
941
- try {
942
- const snapshot = validateRepo(treeDir);
943
- const tagged = snapshot.recipes.find((entry) => entry.key === recipe.key);
944
- if (tagged !== undefined) {
945
- const description = describeVersion(
946
- tagged,
947
- declaredDependencies(tagged, snapshot, settled),
948
- siblingState(snapshot, tagsByKey, existing)
949
- );
950
- return { hash, description, problems: [] };
951
- }
952
- reason = `The repository manifest at that tag does not list '${recipe.key}'.`;
953
- } catch (error) {
954
- reason = describeError(error);
955
- }
956
-
957
- return {
958
- hash,
959
- description: {},
960
- problems: [
961
- {
962
- level: "warning",
963
- where: `${INDEX_FILENAME} recipes['${recipe.key}'].versions['${tag.version}']`,
964
- message:
965
- `version ${tag.version} was rebuilt from the tag '${tag.tag}' without its ` +
966
- `dependencies, because sous could not read the recipe at that tag.\n ${reason}`,
967
- },
968
- ],
969
- };
970
- },
971
- { run }
972
- );
973
- }
974
-
975
- /** Groups release tags by the recipe key they belong to. */
976
- function groupTagsByKey(tags: ReadonlyArray<RecipeTag>): Map<string, RecipeTag[]> {
977
- const grouped = new Map<string, RecipeTag[]>();
978
- for (const tag of tags) {
979
- const key = recipeTagKey(tag);
980
- const list = grouped.get(key);
981
- if (list === undefined) grouped.set(key, [tag]);
982
- else list.push(tag);
983
- }
984
- return grouped;
985
- }
986
-
987
- /** The content hash of a recipe folder as it stood at a tag. */
988
- async function hashTaggedRecipe(
989
- rootDir: string,
990
- tag: string,
991
- recipePath: string,
992
- run: RunOptions["run"]
993
- ): Promise<string> {
994
- return withTaggedTree(rootDir, tag, recipePath, (dir) => hashDirectory(dir), { run });
995
- }
996
-
997
- /** The `releasedAt` field for a tag, or nothing when git cannot date it. */
998
- async function releasedAtOf(
999
- rootDir: string,
1000
- tag: string,
1001
- run: RunOptions["run"]
1002
- ): Promise<{ releasedAt?: string }> {
1003
- const date = await tagCommitDate(rootDir, tag, { run });
1004
- return date === undefined ? {} : { releasedAt: date };
1005
- }
1006
-
1007
- /**
1008
- * Confirms the recipe manifest carried by a tag declares the version the tag
1009
- * names. A tag whose metadata says something else is the exact failure the
1010
- * design forbids: a version hidden behind a tag that disagrees with it.
1011
- */
1012
- async function checkTaggedMetadata(
1013
- rootDir: string,
1014
- tag: string,
1015
- recipePath: string,
1016
- manifestName: string,
1017
- version: string,
1018
- where: string,
1019
- run: RunOptions["run"]
1020
- ): Promise<ValidationProblem[]> {
1021
- const text = await readFileAtTag(rootDir, tag, `${recipePath}/${manifestName}`, { run });
1022
- if (text === undefined) {
1023
- return [
1024
- {
1025
- level: "error",
1026
- where,
1027
- message:
1028
- `the tag '${tag}' does not carry a '${manifestName}' at '${recipePath}'. The tag ` +
1029
- `and the recipe metadata must agree about what was published.`,
1030
- },
1031
- ];
1032
- }
1033
-
1034
- let taggedVersion: string | undefined;
1035
- try {
1036
- const raw = manifestName.endsWith(".json")
1037
- ? parseJsoncText(text, `${tag}:${recipePath}/${manifestName}`)
1038
- : parseYamlText(text, `${tag}:${recipePath}/${manifestName}`);
1039
- taggedVersion = parseRecipeManifest(raw, `${tag}:${recipePath}/${manifestName}`).version;
1040
- } catch (error) {
1041
- return [
1042
- {
1043
- level: "warning",
1044
- where,
1045
- message:
1046
- `the recipe manifest at the tag '${tag}' could not be read, so sous cannot confirm ` +
1047
- `the tag and the metadata agree.\n ${describeError(error)}`,
1048
- },
1049
- ];
1050
- }
1051
-
1052
- if (taggedVersion !== version) {
1053
- return [
1054
- {
1055
- level: "error",
1056
- where,
1057
- message:
1058
- `the tag '${tag}' carries a manifest declaring version ${taggedVersion}, not ` +
1059
- `${version}. Recipe metadata is the source of truth for versions, so the tag is ` +
1060
- `wrong; delete it and let 'sous repo release' cut it again.`,
1061
- },
1062
- ];
1063
- }
1064
-
1065
- return [];
1066
- }
1067
-
1068
- /**
1069
- * Reports release tags that belong to no recipe this repository publishes any
1070
- * more. That is ordinary history after a recipe is renamed or retired, so it is
1071
- * a warning: it is worth saying, and it is not worth stopping for.
1072
- */
1073
- function checkOrphanTags(
1074
- validation: RepoValidation,
1075
- tags: ReadonlyArray<RecipeTag>
1076
- ): ValidationProblem[] {
1077
- const known = new Set(validation.recipes.map((recipe) => recipe.key));
1078
- const orphaned = new Map<string, string[]>();
1079
-
1080
- for (const tag of tags) {
1081
- const key = recipeTagKey(tag);
1082
- if (known.has(key)) continue;
1083
- const list = orphaned.get(key);
1084
- if (list === undefined) orphaned.set(key, [tag.tag]);
1085
- else list.push(tag.tag);
1086
- }
1087
-
1088
- return [...orphaned.entries()].map(([key, tagNames]) => ({
1089
- level: "warning" as const,
1090
- where: `tags for '${key}'`,
1091
- message:
1092
- `this repository carries ${tagNames.length} release ${
1093
- tagNames.length === 1 ? "tag" : "tags"
1094
- } for '${key}' (${tagNames.join(", ")}), which it no longer publishes. That is normal ` +
1095
- `after a recipe is renamed or retired; the tags are left alone.`,
1096
- }));
1097
- }
1098
-
1099
- /** True when two indexes differ only in when they were generated. */
1100
- function sameExceptStamp(left: IndexFile, right: IndexFile): boolean {
1101
- const strip = (index: IndexFile) => stringifyIndexFile({ ...index, generatedAt: "" });
1102
- return strip(left) === strip(right);
1103
- }
1104
-
1105
- /** Renders an absolute path as a repository-relative one, with forward slashes. */
1106
- function relativeTo(rootDir: string, target: string): string {
1107
- return path.relative(rootDir, target).split(path.sep).join("/");
1108
- }