@sous-io/sous 0.1.1 → 0.2.0

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 +408 -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 +619 -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 +413 -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,512 @@
1
+ /**
2
+ * The release plan: what one `sous repo release` run would do, worked out
3
+ * before anything is written.
4
+ *
5
+ * A release is decided by three facts about each recipe, and nothing else:
6
+ *
7
+ * 1. Is it IN SCOPE? By default every recipe the repository publishes is;
8
+ * `--namespace` and `--recipe` narrow it.
9
+ * 2. Has its content CHANGED since the tag that last published it? A recipe
10
+ * nobody touched is not re-released, because a published version that says
11
+ * the same thing as the one before it is noise.
12
+ * 3. Has its version already been RAISED past that tag? If so the author (or
13
+ * a previous run) has already done the bump, and this run only publishes
14
+ * it.
15
+ *
16
+ * From those, the plan says which manifests get a version bump, which tags get
17
+ * cut, and in which order: dependency-first, so a recipe is never published
18
+ * before something it depends on.
19
+ *
20
+ * Nothing here writes anything. The command prints the plan, asks once, and
21
+ * then carries it out.
22
+ */
23
+
24
+ import path from "node:path";
25
+ import semver from "semver";
26
+ import { hashDirectory } from "../store/hash.js";
27
+ import { parseDependencyRef } from "../ref.js";
28
+ import type { RunOptions } from "../providers/git.js";
29
+ import { listRecipeTags, tagFor, withTaggedTree, type RecipeTag } from "./tags.js";
30
+ import { nextVersion, type BumpLevel } from "./bump.js";
31
+ import type { RepoValidation, ValidatedRecipe, ValidationProblem } from "./validate.js";
32
+
33
+ /** Which recipes a run is allowed to touch. */
34
+ export type ReleaseScope = {
35
+ /** Namespaces named with `--namespace`. Empty means every namespace. */
36
+ namespaces: string[];
37
+ /** Recipe keys named with `--recipe`. Empty means every recipe. */
38
+ recipes: string[];
39
+ };
40
+
41
+ /** What is known about one recipe before the plan is made. */
42
+ export type RecipeState = {
43
+ /** The validated recipe. */
44
+ recipe: ValidatedRecipe;
45
+ /** Its key, `namespace/name`. */
46
+ key: string;
47
+ /** The version its manifest declares right now. */
48
+ version: string;
49
+ /** Whether the scope covers it. */
50
+ inScope: boolean;
51
+ /** The highest version any tag publishes, when the recipe has ever been tagged. */
52
+ lastTagged?: string;
53
+ /** The tag carrying that version. */
54
+ lastTag?: string;
55
+ /** Whether the recipe's files differ from what that tag carries. */
56
+ changed: boolean;
57
+ /** Whether a tag already exists for the version the manifest declares. */
58
+ currentVersionTagged: boolean;
59
+ };
60
+
61
+ /** One recipe this run would publish. */
62
+ export type PlannedRelease = {
63
+ /** The recipe key, `namespace/name`. */
64
+ key: string;
65
+ /** The recipe folder, relative to the repository root. */
66
+ path: string;
67
+ /** Absolute path to the recipe's manifest, which a bump rewrites. */
68
+ manifestPath: string;
69
+ /** The version the manifest declares now. */
70
+ from: string;
71
+ /** The version this run publishes. */
72
+ to: string;
73
+ /** The bump this run applies, when it applies one. */
74
+ bump?: BumpLevel;
75
+ /** The tag this run would cut. */
76
+ tag: string;
77
+ };
78
+
79
+ /** One recipe the plan leaves alone, and why. */
80
+ export type SkippedRecipe = {
81
+ key: string;
82
+ /** A complete sentence, ready to print. */
83
+ reason: string;
84
+ };
85
+
86
+ /** What a plan came to. */
87
+ export type ReleasePlan = {
88
+ /** Everything this run would publish, dependency-first. */
89
+ releases: PlannedRelease[];
90
+ /** Recipes the run leaves alone, with the reason for each. */
91
+ skipped: SkippedRecipe[];
92
+ /** Everything wrong with the plan, errors and warnings together. */
93
+ problems: ValidationProblem[];
94
+ /** Every release tag the repository carries. */
95
+ tags: RecipeTag[];
96
+ /** What each recipe looked like when the plan was made. */
97
+ states: RecipeState[];
98
+ };
99
+
100
+ /** What `buildReleasePlan` needs to know. */
101
+ export type BuildReleasePlanOptions = {
102
+ /** The validated repository. */
103
+ validation: RepoValidation;
104
+ /** Which recipes the run may touch. */
105
+ scope: ReleaseScope;
106
+ /** How far to raise a changed recipe's version. Defaults to a patch step. */
107
+ bump?: BumpLevel;
108
+ /** When true, nothing is bumped and an unbumped change is an error. */
109
+ noBump?: boolean;
110
+ /** When true, every recipe in scope is released, changed or not. */
111
+ includeUnchanged?: boolean;
112
+ /** Every release tag, when the caller has already listed them. */
113
+ tags?: RecipeTag[];
114
+ /** The command runner git calls go through. */
115
+ run?: RunOptions["run"];
116
+ };
117
+
118
+ /**
119
+ * Builds the scope from the repeatable `--namespace` and `--recipe` flags.
120
+ *
121
+ * @param namespaces - Namespaces named on the command line.
122
+ * @param recipes - Recipe keys named on the command line.
123
+ */
124
+ export function releaseScope(
125
+ namespaces: string[] = [],
126
+ recipes: string[] = []
127
+ ): ReleaseScope {
128
+ return {
129
+ namespaces: [...new Set(namespaces.map((value) => value.trim()).filter(Boolean))],
130
+ recipes: [...new Set(recipes.map((value) => value.trim()).filter(Boolean))],
131
+ };
132
+ }
133
+
134
+ /** True when the scope covers every recipe the repository publishes. */
135
+ export function scopeIsWholeRepository(scope: ReleaseScope): boolean {
136
+ return scope.namespaces.length === 0 && scope.recipes.length === 0;
137
+ }
138
+
139
+ /**
140
+ * The scope in words, for the run's preamble.
141
+ *
142
+ * @param scope - The scope to describe.
143
+ */
144
+ export function describeScope(scope: ReleaseScope): string {
145
+ if (scopeIsWholeRepository(scope)) return "The whole repository";
146
+ const parts: string[] = [];
147
+ if (scope.namespaces.length > 0) {
148
+ parts.push(
149
+ `${scope.namespaces.length === 1 ? "the namespace" : "the namespaces"} ` +
150
+ scope.namespaces.join(", ")
151
+ );
152
+ }
153
+ if (scope.recipes.length > 0) {
154
+ parts.push(
155
+ `${scope.recipes.length === 1 ? "the recipe" : "the recipes"} ` +
156
+ scope.recipes.join(", ")
157
+ );
158
+ }
159
+ return parts.join(", and ").replace(/^./, (first) => first.toUpperCase());
160
+ }
161
+
162
+ /**
163
+ * Raises a ConfigError-free list of problems for scope entries that name
164
+ * nothing this repository publishes, so a typo is caught before anything runs.
165
+ *
166
+ * @param validation - The validated repository.
167
+ * @param scope - The scope as given.
168
+ */
169
+ export function scopeProblems(
170
+ validation: RepoValidation,
171
+ scope: ReleaseScope
172
+ ): ValidationProblem[] {
173
+ const problems: ValidationProblem[] = [];
174
+ const keys = new Set(validation.recipes.map((recipe) => recipe.key));
175
+ const namespaces = new Set(
176
+ validation.recipes.map((recipe) => recipe.manifest.namespace)
177
+ );
178
+
179
+ for (const namespace of scope.namespaces) {
180
+ if (namespaces.has(namespace)) continue;
181
+ problems.push({
182
+ level: "error",
183
+ where: `--namespace ${namespace}`,
184
+ message:
185
+ `this repository publishes no namespace called '${namespace}'. It publishes: ` +
186
+ `${[...namespaces].sort().join(", ")}.`,
187
+ });
188
+ }
189
+
190
+ for (const key of scope.recipes) {
191
+ if (keys.has(key)) continue;
192
+ problems.push({
193
+ level: "error",
194
+ where: `--recipe ${key}`,
195
+ message:
196
+ `this repository publishes no recipe called '${key}'. It publishes: ` +
197
+ `${[...keys].sort().join(", ")}.`,
198
+ });
199
+ }
200
+
201
+ return problems;
202
+ }
203
+
204
+ /**
205
+ * Works out what one run would publish.
206
+ *
207
+ * @param options - The validated repository, the scope, and the bump rules.
208
+ */
209
+ export async function buildReleasePlan(
210
+ options: BuildReleasePlanOptions
211
+ ): Promise<ReleasePlan> {
212
+ const { validation, scope } = options;
213
+ const run = options.run;
214
+ const tags = options.tags ?? (await listRecipeTags(validation.rootDir, { run }));
215
+ const problems: ValidationProblem[] = [];
216
+
217
+ const states: RecipeState[] = [];
218
+ for (const recipe of validation.recipes) {
219
+ states.push(await readRecipeState(validation.rootDir, recipe, scope, tags, run));
220
+ }
221
+
222
+ const releases: PlannedRelease[] = [];
223
+ const skipped: SkippedRecipe[] = [];
224
+
225
+ for (const state of states) {
226
+ if (!state.inScope) {
227
+ skipped.push({
228
+ key: state.key,
229
+ reason: "it is outside this release's scope.",
230
+ });
231
+ continue;
232
+ }
233
+
234
+ const worthReleasing = state.changed || options.includeUnchanged === true;
235
+ if (!worthReleasing) {
236
+ skipped.push({
237
+ key: state.key,
238
+ reason: `its files have not changed since ${state.lastTag}.`,
239
+ });
240
+ continue;
241
+ }
242
+
243
+ // A version already raised past the last tag needs no bump; this run only
244
+ // publishes it. That is what a merge commit looks like to the CI run.
245
+ const alreadyRaised = state.lastTagged === undefined || !state.currentVersionTagged;
246
+
247
+ if (alreadyRaised) {
248
+ releases.push({
249
+ key: state.key,
250
+ path: state.recipe.path,
251
+ manifestPath: state.recipe.manifestPath,
252
+ from: state.version,
253
+ to: state.version,
254
+ tag: tagOf(state.key, state.version),
255
+ });
256
+ continue;
257
+ }
258
+
259
+ if (options.noBump === true) {
260
+ problems.push({
261
+ level: "error",
262
+ where: relativeTo(validation.rootDir, state.recipe.manifestPath),
263
+ message:
264
+ `version ${state.version} is already published as the tag '${state.lastTag}', ` +
265
+ `and this recipe's files have changed since it. Raise the version in this ` +
266
+ `manifest; a published version never changes.`,
267
+ });
268
+ continue;
269
+ }
270
+
271
+ const level = options.bump ?? "patch";
272
+ const to = nextVersion(state.version, level);
273
+ releases.push({
274
+ key: state.key,
275
+ path: state.recipe.path,
276
+ manifestPath: state.recipe.manifestPath,
277
+ from: state.version,
278
+ to,
279
+ bump: level,
280
+ tag: tagOf(state.key, to),
281
+ });
282
+ }
283
+
284
+ const ordered = orderByDependencies(releases, validation);
285
+ problems.push(...checkSiblings(validation, ordered, states));
286
+
287
+ return { releases: ordered, skipped, problems, tags, states };
288
+ }
289
+
290
+ /**
291
+ * Orders a set of releases dependency-first, so a tag is never cut before the
292
+ * tags it will depend on. Recipes that do not depend on each other keep their
293
+ * original order, which is the order the repo manifest lists them in.
294
+ *
295
+ * A cycle cannot be ordered; the entries in it keep their original order, and
296
+ * the closure check that follows reports it as the real problem.
297
+ *
298
+ * @param releases - The releases to order.
299
+ * @param validation - The validated repository, read for each manifest's siblings.
300
+ */
301
+ export function orderByDependencies(
302
+ releases: PlannedRelease[],
303
+ validation: RepoValidation
304
+ ): PlannedRelease[] {
305
+ const byKey = new Map(releases.map((entry) => [entry.key, entry]));
306
+ const ordered: PlannedRelease[] = [];
307
+ const placed = new Set<string>();
308
+ const visiting = new Set<string>();
309
+
310
+ const place = (key: string): void => {
311
+ if (placed.has(key) || visiting.has(key)) return;
312
+ const entry = byKey.get(key);
313
+ if (entry === undefined) return;
314
+
315
+ visiting.add(key);
316
+ for (const sibling of siblingKeysOf(validation, key)) place(sibling);
317
+ visiting.delete(key);
318
+
319
+ placed.add(key);
320
+ ordered.push(entry);
321
+ };
322
+
323
+ for (const entry of releases) place(entry.key);
324
+ return ordered;
325
+ }
326
+
327
+ /**
328
+ * The keys of the recipes in THIS repository that a recipe depends on. A
329
+ * dependency written as a locator URL lives somewhere else and has nothing to
330
+ * do with the order tags are cut in here.
331
+ *
332
+ * @param validation - The validated repository.
333
+ * @param key - The recipe whose dependencies are wanted.
334
+ */
335
+ export function siblingKeysOf(validation: RepoValidation, key: string): string[] {
336
+ const recipe = validation.recipes.find((entry) => entry.key === key);
337
+ if (recipe === undefined) return [];
338
+
339
+ const declared = [
340
+ ...(recipe.manifest.depends ?? []),
341
+ ...(recipe.manifest.subscribes ?? []),
342
+ ];
343
+
344
+ const keys: string[] = [];
345
+ for (const written of declared) {
346
+ let parsed;
347
+ try {
348
+ parsed = parseDependencyRef(written);
349
+ } catch {
350
+ continue;
351
+ }
352
+ if (parsed.kind !== "sibling") continue;
353
+
354
+ if (parsed.recipe !== undefined) {
355
+ keys.push(`${parsed.namespace}/${parsed.recipe}`);
356
+ continue;
357
+ }
358
+ // A whole-namespace dependency means every recipe in it.
359
+ for (const entry of validation.recipes) {
360
+ if (entry.manifest.namespace === parsed.namespace && entry.key !== key) {
361
+ keys.push(entry.key);
362
+ }
363
+ }
364
+ }
365
+
366
+ return [...new Set(keys)];
367
+ }
368
+
369
+ /**
370
+ * Checks the sibling rule: everything a released recipe depends on inside this
371
+ * repository has to be a version that exists once this run's own tags are
372
+ * counted.
373
+ *
374
+ * There are exactly two ways that fails, and they are different kinds of thing:
375
+ *
376
+ * - The sibling has never been tagged at all. Nothing can depend on it, so
377
+ * this is an error naming the tag that has to be cut.
378
+ * - The sibling HAS been tagged, and has changed since, but is outside this
379
+ * release's scope. The release is still correct: it will depend on the last
380
+ * tagged version. That is worth saying, so it is a warning, and the warning
381
+ * states only what is verifiable.
382
+ *
383
+ * @param validation - The validated repository.
384
+ * @param releases - The releases this run would publish.
385
+ * @param states - What each recipe looked like when the plan was made.
386
+ */
387
+ function checkSiblings(
388
+ validation: RepoValidation,
389
+ releases: PlannedRelease[],
390
+ states: RecipeState[]
391
+ ): ValidationProblem[] {
392
+ const problems: ValidationProblem[] = [];
393
+ const releasing = new Map(releases.map((entry) => [entry.key, entry]));
394
+ const byKey = new Map(states.map((state) => [state.key, state]));
395
+
396
+ for (const release of releases) {
397
+ for (const siblingKey of siblingKeysOf(validation, release.key)) {
398
+ if (releasing.has(siblingKey)) continue;
399
+
400
+ const sibling = byKey.get(siblingKey);
401
+ const where = relativeTo(validation.rootDir, release.manifestPath);
402
+
403
+ if (sibling === undefined) {
404
+ problems.push({
405
+ level: "error",
406
+ where,
407
+ message:
408
+ `it depends on '${siblingKey}', which this repository does not publish. A ` +
409
+ `dependency written without a location names a recipe in this same ` +
410
+ `repository.`,
411
+ });
412
+ continue;
413
+ }
414
+
415
+ if (sibling.lastTagged === undefined) {
416
+ problems.push({
417
+ level: "error",
418
+ where,
419
+ message:
420
+ `it depends on '${siblingKey}', which has never been published: this ` +
421
+ `repository carries no tag for it. Release it first, which cuts the tag ` +
422
+ `'${tagOf(siblingKey, sibling.version)}'.`,
423
+ });
424
+ continue;
425
+ }
426
+
427
+ if (sibling.changed) {
428
+ problems.push({
429
+ level: "warning",
430
+ where,
431
+ message:
432
+ `'${siblingKey}' has changes since '${sibling.lastTag}' that are outside ` +
433
+ `this release's scope; '${release.tag}' will depend on ` +
434
+ `'${sibling.lastTag}'.`,
435
+ });
436
+ }
437
+ }
438
+ }
439
+
440
+ return problems;
441
+ }
442
+
443
+ /**
444
+ * Reads the three facts a plan is made of for one recipe: whether the scope
445
+ * covers it, what tag last published it, and whether its files have changed
446
+ * since that tag.
447
+ *
448
+ * @param rootDir - The repository's root directory.
449
+ * @param recipe - The validated recipe.
450
+ * @param scope - The run's scope.
451
+ * @param tags - Every release tag the repository carries.
452
+ * @param run - The command runner git calls go through.
453
+ */
454
+ async function readRecipeState(
455
+ rootDir: string,
456
+ recipe: ValidatedRecipe,
457
+ scope: ReleaseScope,
458
+ tags: ReadonlyArray<RecipeTag>,
459
+ run: RunOptions["run"]
460
+ ): Promise<RecipeState> {
461
+ const key = recipe.key;
462
+ const version = recipe.manifest.version;
463
+ const mine = tags.filter((tag) => `${tag.namespace}/${tag.name}` === key);
464
+
465
+ const highest = mine
466
+ .map((tag) => tag.version)
467
+ .filter((value) => semver.valid(value) !== null)
468
+ .sort((left, right) => semver.rcompare(left, right))[0];
469
+
470
+ const state: RecipeState = {
471
+ recipe,
472
+ key,
473
+ version,
474
+ inScope: coveredByScope(recipe, scope),
475
+ changed: true,
476
+ currentVersionTagged: mine.some((tag) => tag.version === version),
477
+ ...(highest === undefined ? {} : { lastTagged: highest, lastTag: tagOf(key, highest) }),
478
+ };
479
+
480
+ if (highest === undefined) return state;
481
+
482
+ // "Changed" is a content question, so it is answered by hashing, exactly the
483
+ // way a consumer decides whether a cached copy is still the published one.
484
+ const workingHash = await hashDirectory(recipe.dir);
485
+ const taggedHash = await withTaggedTree(
486
+ rootDir,
487
+ tagOf(key, highest),
488
+ recipe.path,
489
+ (dir) => hashDirectory(dir),
490
+ { run }
491
+ );
492
+ state.changed = workingHash !== taggedHash;
493
+ return state;
494
+ }
495
+
496
+ /** True when a run's scope covers a recipe. */
497
+ function coveredByScope(recipe: ValidatedRecipe, scope: ReleaseScope): boolean {
498
+ if (scopeIsWholeRepository(scope)) return true;
499
+ if (scope.namespaces.includes(recipe.manifest.namespace)) return true;
500
+ return scope.recipes.includes(recipe.key);
501
+ }
502
+
503
+ /** The tag that publishes one version of one recipe key. */
504
+ function tagOf(key: string, version: string): string {
505
+ const slash = key.indexOf("/");
506
+ return tagFor(key.slice(0, slash), key.slice(slash + 1), version);
507
+ }
508
+
509
+ /** Renders an absolute path as a repository-relative one, with forward slashes. */
510
+ function relativeTo(rootDir: string, target: string): string {
511
+ return path.relative(rootDir, target).split(path.sep).join("/");
512
+ }