@sous-io/sous 0.2.24 → 0.2.26

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.
@@ -25,3 +25,4 @@
25
25
  - [0004: A global sous defers to the project's install](adrs/0004-project-install-handoff.md)
26
26
  - [0005: Follow-ups from the first project on the hand-off](adrs/0005-first-project-follow-ups.md)
27
27
  - [0006: `sous repo init` follows `sous-recipes`](adrs/0006-repo-init-model-home.md)
28
+ - [0007: Frozen index entries and tolerant readers](adrs/0007-frozen-index-dependencies.md)
@@ -240,9 +240,10 @@ this release's scope** is fine: the release depends on the last published versio
240
240
 
241
241
  Each version's resolved dependencies are written into the index, so a consumer installing that version gets what
242
242
  it was published with rather than ranges re-resolved months later. A published version never changes: its content
243
- hash is carried forward exactly as published, and a disagreement is an error telling you to bump rather than
244
- republish. A version counts as published once its tag exists, and the tags are the backstop, so a tagged version
245
- missing from the index is rebuilt from it whenever the index is regenerated, which only happens on a run that
243
+ hash and its dependencies are carried forward exactly as published, and a disagreement is an error telling you
244
+ to bump rather than republish ([what is checked](repositories-file-formats.md#sousindexjson)). A version counts
245
+ as published once its tag exists, and the tags are the backstop, so a tagged version missing from the index is
246
+ rebuilt from it, dependencies included, whenever the index is regenerated, which only happens on a run that
246
247
  publishes something. A bump edits the manifest in place, so comments, field order and layout survive; a folded
247
248
  block of YAML prose may be re-wrapped and the space before a trailing comment collapsed to one.
248
249
 
@@ -177,6 +177,22 @@ sibling, resolved exactly) or `range` plus `repo` (the identity of the repositor
177
177
  index resolves the range); at least one is required. Installing a version installs these rather than
178
178
  re-resolving the manifest's ranges.
179
179
 
180
+ **A published version's entry is frozen.** Its dependencies are resolved once, when the version is first
181
+ recorded, and every later release carries the entry forward exactly as published, like its hash; releasing
182
+ other recipes, adding a recipe to a namespace it subscribes to, or raising a sibling it names changes nothing
183
+ in it. A release still checks the recorded list against the version's manifest: a recipe the manifest names
184
+ that the list lacks, a recipe in the list the manifest does not declare, a sibling outside its declared
185
+ range, or another repository's entry with a different repository or range is an error naming the recipe, the
186
+ version and each difference; restore the entry if the index was edited, or bump the version to publish
187
+ different dependencies. A version rebuilt from its tag, when the index lost it, has its dependencies resolved
188
+ against the repository as it stood at that tag, then frozen the same way. An entry written before sous
189
+ recorded dependencies carries none, stays that way, and installs by resolving its manifest's ranges.
190
+
191
+ **Fields this sous does not know are kept, not refused.** A later sous may add fields to the index, at any
192
+ level. This one reads such an index, validates every field it defines as strictly as before, and ignores the
193
+ rest; a release it runs carries a published version's entry forward with those fields intact. A sous older
194
+ than the one that began tolerating unknown fields refuses the whole index once a repository publishes one.
195
+
180
196
  **The `seeded` field.** Sous ships the `core` recipe in its own npm package, so a project can build before
181
197
  reaching the network. That copy is folded into the official repository's index in memory and resolved like
182
198
  any other, marked `seeded: true`; if the repository already publishes it, the published copy wins. Sous
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sous-io/sous",
3
- "version": "0.2.24",
3
+ "version": "0.2.26",
4
4
  "description": "Compiles AI coding agent configuration (CLAUDE.md, skills, memories) from LiquidJS templates",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -9,7 +9,7 @@
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/sous-io/sous.git"
11
11
  },
12
- "homepage": "https://sous-io.github.io/sous",
12
+ "homepage": "https://sous.io",
13
13
  "bugs": "https://github.com/sous-io/sous/issues",
14
14
  "keywords": [
15
15
  "cli",
@@ -75,7 +75,7 @@ at `{{ sousRootPath }}/docs/markdown/`. Read `_sidebar.md` there first; it is
75
75
  the index of what exists. When you need to understand a sous feature beyond
76
76
  what the skills cover, read these files before guessing or searching the web:
77
77
  they match the INSTALLED version of sous, unlike the online copy
78
- (https://sous-io.github.io/sous/markdown/#/), which tracks the latest release.
78
+ (https://sous.io/markdown/#/), which tracks the latest release.
79
79
  The reference content is still being written; the index shows what exists so
80
80
  far.
81
81
 
@@ -48,7 +48,7 @@ Read these files for anything beyond this page; do not guess and do not search t
48
48
  Schema artifact; the `sous config` commands
49
49
 
50
50
  These files match the INSTALLED sous version. The same content is published at
51
- https://sous-io.github.io/sous/markdown/#/configuration, which tracks the latest release;
51
+ https://sous.io/markdown/#/configuration, which tracks the latest release;
52
52
  prefer the on-disk copies.
53
53
 
54
54
  ## Rules
@@ -11,7 +11,7 @@ formatVersion: 1
11
11
 
12
12
  namespace: core
13
13
  name: sous-skills
14
- version: 0.2.24
14
+ version: 0.2.26
15
15
 
16
16
  description: >-
17
17
  The skills that teach an agent what sous is and how it works: which files sous
@@ -307,12 +307,29 @@ function stripExtensionKeys(value: unknown): unknown {
307
307
  * Builds a strict object schema for a HAND-WRITTEN format: unknown keys are
308
308
  * rejected so typos surface immediately, except keys in the reserved `x-`
309
309
  * extension namespace, which are accepted and ignored. Machine-written formats
310
- * use plain `z.strictObject` instead; nothing writes extension keys into them.
310
+ * use `z.strictObject` or `forwardCompatibleObject` instead; nothing writes
311
+ * extension keys into them.
311
312
  */
312
313
  export function extensibleObject<Shape extends z.ZodRawShape>(shape: Shape) {
313
314
  return z.preprocess(stripExtensionKeys, z.strictObject(shape));
314
315
  }
315
316
 
317
+ /**
318
+ * Builds an object schema for a machine-written format that other versions of
319
+ * sous publish and this one reads: the repo index. Every known key is validated
320
+ * exactly as strictly as `z.strictObject` would, and a key this version does not
321
+ * define is kept, unread, rather than refused.
322
+ *
323
+ * A later sous adds fields to the index, and a repository published by it is
324
+ * read by every older sous a project happens to pin; refusing the whole index
325
+ * over a field it cannot use would break those projects for nothing. The key is
326
+ * kept rather than dropped, so a release carrying a published version forward
327
+ * writes it back byte for byte, whichever sous runs the release.
328
+ */
329
+ export function forwardCompatibleObject<Shape extends z.ZodRawShape>(shape: Shape) {
330
+ return z.looseObject(shape);
331
+ }
332
+
316
333
  /**
317
334
  * The `submissions` block a repo manifest and a recipe manifest may both carry:
318
335
  * whether the recipes it covers take proposed changes, and where to send a
@@ -9,12 +9,17 @@
9
9
  *
10
10
  * Adding a repo fetches only this file. Nothing else is downloaded until a
11
11
  * project subscribes to something inside it.
12
+ *
13
+ * Every object in it is a `forwardCompatibleObject`: a field a later sous adds is
14
+ * kept and ignored rather than refused, so an index published by a newer sous
15
+ * still reads here. The fields this version defines are validated in full.
12
16
  */
13
17
 
14
18
  import { z } from "zod";
15
19
  import {
16
20
  contentHashSchema,
17
21
  formatVersionSchema,
22
+ forwardCompatibleObject,
18
23
  isoTimestampSchema,
19
24
  namespaceNameSchema,
20
25
  parseFormat,
@@ -38,30 +43,25 @@ import {
38
43
  * belongs to that repository's own index, so this entry carries the range the
39
44
  * manifest declared instead.
40
45
  */
41
- export const indexDependencySchema = z
42
- .strictObject({
43
- /** The exact version this dependency resolved to, when the release could resolve one. */
44
- version: semverVersionSchema.optional(),
45
- /** The range the manifest declared, recorded when no exact version could be resolved. */
46
- range: semverRangeSchema.optional(),
47
- /**
48
- * The canonical identity of the repository publishing it
49
- * (`github.com/sous-io/sous-recipes`). Omitted for a sibling, which lives in
50
- * this same repository.
51
- */
52
- repo: repoIdentitySchema.optional(),
53
- })
54
- .refine(
55
- (entry) => entry.version !== undefined || entry.range !== undefined,
56
- {
57
- message:
58
- "must record either the exact version this dependency resolved to or the range " +
59
- "the recipe declared",
60
- }
61
- );
46
+ export const indexDependencySchema = forwardCompatibleObject({
47
+ /** The exact version this dependency resolved to, when the release could resolve one. */
48
+ version: semverVersionSchema.optional(),
49
+ /** The range the manifest declared, recorded when no exact version could be resolved. */
50
+ range: semverRangeSchema.optional(),
51
+ /**
52
+ * The canonical identity of the repository publishing it
53
+ * (`github.com/sous-io/sous-recipes`). Omitted for a sibling, which lives in
54
+ * this same repository.
55
+ */
56
+ repo: repoIdentitySchema.optional(),
57
+ }).refine((entry) => entry.version !== undefined || entry.range !== undefined, {
58
+ message:
59
+ "must record either the exact version this dependency resolved to or the range " +
60
+ "the recipe declared",
61
+ });
62
62
 
63
63
  /** One published version of one recipe. */
64
- export const indexVersionSchema = z.strictObject({
64
+ export const indexVersionSchema = forwardCompatibleObject({
65
65
  /** Content hash of the recipe folder at this version, verified after every fetch. */
66
66
  hash: contentHashSchema,
67
67
  /**
@@ -98,7 +98,7 @@ export const indexVersionSchema = z.strictObject({
98
98
  });
99
99
 
100
100
  /** One recipe, with every version the repo publishes of it. */
101
- export const indexRecipeSchema = z.strictObject({
101
+ export const indexRecipeSchema = forwardCompatibleObject({
102
102
  /** The recipe folder, relative to the repo root. */
103
103
  path: relativePathSchema("a recipe path"),
104
104
  /** One-paragraph summary, copied from the recipe manifest at release time. */
@@ -112,71 +112,69 @@ export const indexRecipeSchema = z.strictObject({
112
112
  });
113
113
 
114
114
  /** One namespace declaration, copied from the repo manifest at release time. */
115
- export const indexNamespaceSchema = z.strictObject({
115
+ export const indexNamespaceSchema = forwardCompatibleObject({
116
116
  description: z.string().optional(),
117
117
  });
118
118
 
119
119
  /** The repo index schema. */
120
- export const indexFileSchema = z
121
- .strictObject({
122
- /**
123
- * A plain-language note about where this copy of the index came from. JSON
124
- * has no comment syntax and an index is machine-written, so this is the one
125
- * place a writer can say something to whoever opens the file. Sous ignores
126
- * the value everywhere except one place: the seed index it writes for its
127
- * own built-in repository carries `SEED_INDEX_COMMENT`, which is how a
128
- * later run recognizes its own placeholder and is willing to replace it.
129
- */
130
- $comment: z.string().optional(),
131
- formatVersion: formatVersionSchema,
132
- /** The repo's suggested short name, copied from its manifest. */
133
- name: repoNameSchema,
134
- /** When this index was generated. */
135
- generatedAt: isoTimestampSchema,
136
- /** The version of sous that generated it. */
137
- generator: semverVersionSchema,
138
- /** Every namespace the repo publishes. */
139
- namespaces: z.record(namespaceNameSchema, indexNamespaceSchema),
140
- /** Every recipe the repo publishes, keyed `namespace/recipe`. */
141
- recipes: z.record(recipeKeySchema, indexRecipeSchema),
142
- })
143
- .superRefine((index, ctx) => {
144
- // A recipe whose namespace is not declared could never be resolved, so a
145
- // release that produced one is broken; say which recipe and which namespace.
146
- for (const key of Object.keys(index.recipes)) {
147
- const namespace = key.slice(0, key.indexOf("/"));
148
- if (!Object.hasOwn(index.namespaces, namespace)) {
149
- ctx.addIssue({
150
- code: "custom",
151
- path: ["recipes", key],
152
- message:
153
- `belongs to the namespace '${namespace}', which this index does not ` +
154
- `declare under 'namespaces'`,
155
- });
156
- }
120
+ export const indexFileSchema = forwardCompatibleObject({
121
+ /**
122
+ * A plain-language note about where this copy of the index came from. JSON
123
+ * has no comment syntax and an index is machine-written, so this is the one
124
+ * place a writer can say something to whoever opens the file. Sous ignores
125
+ * the value everywhere except one place: the seed index it writes for its
126
+ * own built-in repository carries `SEED_INDEX_COMMENT`, which is how a
127
+ * later run recognizes its own placeholder and is willing to replace it.
128
+ */
129
+ $comment: z.string().optional(),
130
+ formatVersion: formatVersionSchema,
131
+ /** The repo's suggested short name, copied from its manifest. */
132
+ name: repoNameSchema,
133
+ /** When this index was generated. */
134
+ generatedAt: isoTimestampSchema,
135
+ /** The version of sous that generated it. */
136
+ generator: semverVersionSchema,
137
+ /** Every namespace the repo publishes. */
138
+ namespaces: z.record(namespaceNameSchema, indexNamespaceSchema),
139
+ /** Every recipe the repo publishes, keyed `namespace/recipe`. */
140
+ recipes: z.record(recipeKeySchema, indexRecipeSchema),
141
+ }).superRefine((index, ctx) => {
142
+ // A recipe whose namespace is not declared could never be resolved, so a
143
+ // release that produced one is broken; say which recipe and which namespace.
144
+ for (const key of Object.keys(index.recipes)) {
145
+ const namespace = key.slice(0, key.indexOf("/"));
146
+ if (!Object.hasOwn(index.namespaces, namespace)) {
147
+ ctx.addIssue({
148
+ code: "custom",
149
+ path: ["recipes", key],
150
+ message:
151
+ `belongs to the namespace '${namespace}', which this index does not ` +
152
+ `declare under 'namespaces'`,
153
+ });
157
154
  }
155
+ }
158
156
 
159
- // A version's tag is what the provider fetches, so a tag that does not
160
- // name this exact recipe and version is a version pointing somewhere else.
161
- // Nothing on the consumer side could otherwise tell: an index publishing
162
- // `1.0.0` with `tag: "main"` would hand `git clone --branch main` a moving
163
- // target, whose content changes on every push and whose pinned hash then
164
- // simply starts failing. Sous writes these tags itself, so requiring the
165
- // shape it writes costs a correct index nothing.
166
- for (const [key, recipe] of Object.entries(index.recipes)) {
167
- for (const [version, published] of Object.entries(recipe.versions)) {
168
- const expected = `${key}@${version}`;
169
- if (published.tag === expected) continue;
170
- ctx.addIssue({
171
- code: "custom",
172
- path: ["recipes", key, "versions", version, "tag"],
173
- message:
174
- `is '${published.tag}', but a published version's tag names the recipe and ` +
175
- `the version it carries, so this one must be '${expected}'`,
176
- });
177
- }
157
+ // A version's tag is what the provider fetches, so a tag that does not
158
+ // name this exact recipe and version is a version pointing somewhere else.
159
+ // Nothing on the consumer side could otherwise tell: an index publishing
160
+ // `1.0.0` with `tag: "main"` would hand `git clone --branch main` a moving
161
+ // target, whose content changes on every push and whose pinned hash then
162
+ // simply starts failing. Sous writes these tags itself, so requiring the
163
+ // shape it writes costs a correct index nothing.
164
+ for (const [key, recipe] of Object.entries(index.recipes)) {
165
+ for (const [version, published] of Object.entries(recipe.versions)) {
166
+ const expected = `${key}@${version}`;
167
+ if (published.tag === expected) continue;
168
+ ctx.addIssue({
169
+ code: "custom",
170
+ path: ["recipes", key, "versions", version, "tag"],
171
+ message:
172
+ `is '${published.tag}', but a published version's tag names the recipe and ` +
173
+ `the version it carries, so this one must be '${expected}'`,
174
+ });
178
175
  }
179
- });
176
+ }
177
+ });
180
178
 
181
179
  /** A validated repo index. */
182
180
  export type IndexFile = z.infer<typeof indexFileSchema>;
@@ -6,8 +6,10 @@
6
6
  * and this module exists to keep them:
7
7
  *
8
8
  * 1. A published version is IMMUTABLE. An entry that is already in the index
9
- * keeps the hash it was published with; a recomputed hash that disagrees is
10
- * reported as an error, never quietly written over.
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.
11
13
  * 2. A version is published when a tag carries it, and every version the index
12
14
  * records has to have one. The single exception is the version a recipe's
13
15
  * manifest declares right now: a release regenerates the index, commits it
@@ -16,12 +18,14 @@
16
18
  * OLDER version without a tag is an error, because that is a published
17
19
  * version nothing can fetch.
18
20
  * 3. The tags are the backstop. A version that has a tag but is missing from the
19
- * index is rebuilt from the tagged tree, so deleting the index file and
20
- * regenerating it restores the same catalog.
21
+ * index is rebuilt from the tagged tree, dependencies included, so deleting
22
+ * the index file and regenerating it restores the same catalog.
21
23
  *
22
24
  * Each version entry also records what it was released against, so a consumer
23
25
  * installs the versions a recipe was published with rather than re-resolving
24
- * its ranges long afterwards.
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.
25
29
  */
26
30
 
27
31
  import fs from "node:fs";
@@ -51,6 +55,7 @@ import {
51
55
  } from "./tags.js";
52
56
  import {
53
57
  describeError,
58
+ validateRepo,
54
59
  type RepoValidation,
55
60
  type ValidatedRecipe,
56
61
  type ValidationProblem,
@@ -160,20 +165,7 @@ export async function buildIndex(
160
165
  // and every version each of them publishes. Both are needed to turn a
161
166
  // dependency's declared range into the exact version a consumer installs.
162
167
  const publishing = options.publishing ?? {};
163
- const siblingVersions = new Map<string, string>();
164
- const publishedVersions = new Map<string, string[]>();
165
- for (const recipe of validation.recipes) {
166
- const settled = publishing[recipe.key] ?? recipe.manifest.version;
167
- siblingVersions.set(recipe.key, settled);
168
- const known = new Set(
169
- (tagsByKey.get(recipe.key) ?? []).map((tag) => tag.version)
170
- );
171
- for (const version of Object.keys(existing?.recipes[recipe.key]?.versions ?? {})) {
172
- known.add(version);
173
- }
174
- known.add(settled);
175
- publishedVersions.set(recipe.key, [...known]);
176
- }
168
+ const current = siblingState(validation, tagsByKey, existing, publishing);
177
169
 
178
170
  for (const recipe of validation.recipes) {
179
171
  if (!Object.hasOwn(namespaces, recipe.manifest.namespace)) continue;
@@ -185,14 +177,18 @@ export async function buildIndex(
185
177
  const manifestName = path.basename(recipe.manifestPath);
186
178
 
187
179
  // Rule 3: every tagged version belongs in the index, including ones the
188
- // committed index has lost.
180
+ // committed index has lost. Its dependencies are resolved against the
181
+ // repository as it stood at the tag, which is what it was released against.
189
182
  for (const tag of recipeTags) {
190
183
  if (Object.hasOwn(versions, tag.version)) continue;
184
+ const rebuilt = await rebuildTaggedVersion(rootDir, tag, recipe, tagsByKey, existing, run);
185
+ problems.push(...rebuilt.problems);
191
186
  versions[tag.version] = {
192
- hash: await hashTaggedRecipe(rootDir, tag.tag, recipe.path, run),
187
+ hash: rebuilt.hash,
193
188
  tag: tag.tag,
194
189
  prerelease: semver.prerelease(tag.version) !== null,
195
190
  ...(await releasedAtOf(rootDir, tag.tag, run)),
191
+ ...(rebuilt.dependencies === undefined ? {} : { dependencies: rebuilt.dependencies }),
196
192
  };
197
193
  }
198
194
 
@@ -201,12 +197,7 @@ export async function buildIndex(
201
197
  const hasTag = recipeTags.some((entry) => entry.tag === tagName);
202
198
  const workingHash = await hashDirectory(recipe.dir);
203
199
  const where = relativeTo(rootDir, recipe.manifestPath);
204
- const dependencies = resolveIndexDependencies(
205
- recipe,
206
- validation,
207
- siblingVersions,
208
- publishedVersions
209
- );
200
+ const declared = declaredDependencies(recipe, validation);
210
201
 
211
202
  // A version the index publishes must have a tag, with one exception: the
212
203
  // version the manifest declares right now, which is the one a release is in
@@ -242,21 +233,24 @@ export async function buildIndex(
242
233
  `now carries. A published version never changes, so either the tag was moved ` +
243
234
  `or the index was edited; restore whichever is wrong before releasing again.`,
244
235
  });
245
- } else {
246
- // A release records a version before its tag exists, dating it from the
247
- // run; the tag that follows carries a date of its own. Keeping the date
248
- // the index already published is what stops the very next regeneration
249
- // from calling a perfectly current index stale.
250
- versions[version] = {
251
- hash: taggedHash,
252
- tag: tagName,
253
- prerelease: semver.prerelease(version) !== null,
254
- ...(published?.releasedAt === undefined
255
- ? await releasedAtOf(rootDir, tagName, run)
256
- : { releasedAt: published.releasedAt }),
257
- ...(dependencies === undefined ? {} : { dependencies }),
258
- };
236
+ } else if (published !== undefined) {
237
+ // Already published: the entry is carried forward as it stands, its
238
+ // dependencies and any field a later sous wrote included. Only a missing
239
+ // release date is filled in, from the tag.
240
+ if (published.releasedAt === undefined) {
241
+ versions[version] = { ...published, ...(await releasedAtOf(rootDir, tagName, run)) };
242
+ }
243
+ // The check needs the manifest that was published, which the working one
244
+ // is only while the folder still matches the tag. An entry with no
245
+ // dependencies at all was published before sous recorded them; a
246
+ // consumer resolves that version's ranges, and it stays that way.
247
+ if (taggedHash === workingHash && published.dependencies !== undefined) {
248
+ problems.push(
249
+ ...checkRecordedDependencies(key, version, published.dependencies, declared)
250
+ );
251
+ }
259
252
  }
253
+ // A tagged version the index did not record was rebuilt from its tag above.
260
254
 
261
255
  if (taggedHash !== workingHash) {
262
256
  // A recipe this run is not publishing is allowed to have unpublished
@@ -284,6 +278,7 @@ export async function buildIndex(
284
278
  // follows on the very commit that carries this index.
285
279
  const published = existingVersions[version];
286
280
  if (options.publishing?.[key] === version || published !== undefined) {
281
+ const dependencies = resolveIndexDependencies(declared, current);
287
282
  versions[version] = {
288
283
  hash: workingHash,
289
284
  tag: tagName,
@@ -420,50 +415,76 @@ export function describeIndexDrift(
420
415
  // --- Internals ----------------------------------------------------------------------------------
421
416
 
422
417
  /**
423
- * Turns one recipe's declared dependencies into the exact versions its index
424
- * entry publishes.
425
- *
426
- * This is the whole point of recording dependencies in the index: a consumer
427
- * installing version 1.4.0 of a recipe installs what 1.4.0 was released
428
- * against, not whatever its ranges happen to reach months later.
429
- *
430
- * - A SIBLING with no range means "the version released alongside me", which
431
- * is the version this run settles that sibling on.
432
- * - A SIBLING with a range resolves against the versions this repository
433
- * publishes, including the ones this run is adding.
434
- * - A CROSS-REPOSITORY dependency carries the identity of the repository it
435
- * lives in, which is what a consumer needs in order to add that repository
436
- * and find the recipe. Its exact version belongs to that repository's own
437
- * index, so the range the manifest declared is recorded instead.
418
+ * One dependency a recipe's manifest declares, keyed `namespace/recipe` in
419
+ * `DeclaredDependencies`. A whole-namespace declaration is expanded into one
420
+ * entry per recipe the namespace holds.
421
+ */
422
+ type DeclaredDependency =
423
+ | {
424
+ /** A recipe in another repository, named by its location. */
425
+ kind: "remote";
426
+ /** The canonical identity of the repository publishing it. */
427
+ repo: string;
428
+ /** The range the manifest declared, `*` when it declared none. */
429
+ range: string;
430
+ }
431
+ | {
432
+ /** A recipe in this same repository. */
433
+ kind: "sibling";
434
+ /** The range the manifest declared, when it declared one. */
435
+ range?: string;
436
+ /**
437
+ * True when the manifest names this recipe itself, rather than only the
438
+ * namespace holding it. A recipe a namespace gained after a version was
439
+ * published is rightly missing from that version's entry; one the
440
+ * manifest names is not.
441
+ */
442
+ named: boolean;
443
+ };
444
+
445
+ /** Everything one recipe's manifest declares, under `depends` and `subscribes`. */
446
+ type DeclaredDependencies = {
447
+ /** Every dependency, keyed `namespace/recipe`; a later declaration wins over an earlier one. */
448
+ byKey: Map<string, DeclaredDependency>;
449
+ /** The namespaces of this repository the manifest declares whole. */
450
+ namespaces: Set<string>;
451
+ };
452
+
453
+ /** What each recipe of a repository is published at, as one resolution sees it. */
454
+ type SiblingState = {
455
+ /** The version each recipe is released at, which is what "released alongside me" means. */
456
+ settled: Map<string, string>;
457
+ /** Every version each recipe publishes, for resolving a declared range. */
458
+ published: Map<string, string[]>;
459
+ };
460
+
461
+ /**
462
+ * Reads the dependencies a recipe's manifest declares, expanding a
463
+ * whole-namespace declaration into the recipes that namespace holds.
438
464
  *
439
- * @param recipe - The recipe whose dependencies are being resolved.
465
+ * @param recipe - The recipe whose dependencies are being read.
440
466
  * @param validation - The validated repository, for expanding namespace refs.
441
- * @param siblingVersions - What each recipe here is published at after this run.
442
- * @param publishedVersions - Every version each recipe here publishes.
443
467
  */
444
- function resolveIndexDependencies(
468
+ function declaredDependencies(
445
469
  recipe: ValidatedRecipe,
446
- validation: RepoValidation,
447
- siblingVersions: Map<string, string>,
448
- publishedVersions: Map<string, string[]>
449
- ): Record<string, IndexDependency> | undefined {
470
+ validation: RepoValidation
471
+ ): DeclaredDependencies {
472
+ const byKey = new Map<string, DeclaredDependency>();
473
+ const namespaces = new Set<string>();
450
474
  const declared = [
451
475
  ...(recipe.manifest.depends ?? []),
452
476
  ...(recipe.manifest.subscribes ?? []),
453
477
  ];
454
- if (declared.length === 0) return undefined;
455
478
 
456
- const resolved: Record<string, IndexDependency> = {};
457
-
458
- /** Records one sibling, at the version this run settles it on. */
459
- const recordSibling = (key: string, range: string | undefined): void => {
460
- if (range === undefined) {
461
- const settled = siblingVersions.get(key);
462
- resolved[key] = settled === undefined ? { range: "*" } : { version: settled };
463
- return;
464
- }
465
- const best = semver.maxSatisfying(publishedVersions.get(key) ?? [], range);
466
- resolved[key] = best === null ? { range } : { version: best };
479
+ /** Records one sibling, remembering whether any declaration named it. */
480
+ const addSibling = (key: string, range: string | undefined, named: boolean): void => {
481
+ const before = byKey.get(key);
482
+ const namedBefore = before?.kind === "sibling" && before.named;
483
+ byKey.set(key, {
484
+ kind: "sibling",
485
+ ...(range === undefined ? {} : { range }),
486
+ named: named || namedBefore,
487
+ });
467
488
  };
468
489
 
469
490
  for (const written of declared) {
@@ -476,29 +497,299 @@ function resolveIndexDependencies(
476
497
  }
477
498
 
478
499
  if (parsed.kind === "remote") {
479
- resolved[`${parsed.namespace}/${parsed.recipe}`] = {
500
+ byKey.set(`${parsed.namespace}/${parsed.recipe}`, {
501
+ kind: "remote",
480
502
  repo: parsed.canonicalRepo!,
481
503
  range: parsed.range ?? "*",
482
- };
504
+ });
483
505
  continue;
484
506
  }
485
507
 
486
508
  if (parsed.recipe !== undefined) {
487
- recordSibling(`${parsed.namespace}/${parsed.recipe}`, parsed.range);
509
+ addSibling(`${parsed.namespace}/${parsed.recipe}`, parsed.range, true);
488
510
  continue;
489
511
  }
490
512
 
491
513
  // A whole-namespace dependency means every recipe in that namespace.
514
+ namespaces.add(parsed.namespace);
492
515
  for (const entry of validation.recipes) {
493
516
  if (entry.manifest.namespace !== parsed.namespace) continue;
494
517
  if (entry.key === recipe.key) continue;
495
- recordSibling(entry.key, undefined);
518
+ addSibling(entry.key, undefined, false);
519
+ }
520
+ }
521
+
522
+ return { byKey, namespaces };
523
+ }
524
+
525
+ /**
526
+ * Works out what each recipe of a repository is published at, for resolving
527
+ * the dependencies of a version being recorded.
528
+ *
529
+ * Two moments call this. A run recording a NEW version resolves against the
530
+ * repository as it stands, counting the versions the run itself is publishing.
531
+ * A run rebuilding a version from its TAG resolves against the repository as
532
+ * it stood at that tag (no `publishing` given): a sibling is settled on the
533
+ * version its manifest declared there, and only versions up to that one count
534
+ * as published.
535
+ *
536
+ * @param validation - The repository, as it stands or as it stood at a tag.
537
+ * @param tagsByKey - Every release tag, grouped by recipe.
538
+ * @param existing - The committed index, when there is one.
539
+ * @param publishing - The versions this run publishes; omitted for a tag.
540
+ */
541
+ function siblingState(
542
+ validation: RepoValidation,
543
+ tagsByKey: Map<string, RecipeTag[]>,
544
+ existing: IndexFile | undefined,
545
+ publishing?: Record<string, string>
546
+ ): SiblingState {
547
+ const settled = new Map<string, string>();
548
+ const published = new Map<string, string[]>();
549
+
550
+ for (const recipe of validation.recipes) {
551
+ const version = publishing?.[recipe.key] ?? recipe.manifest.version;
552
+ settled.set(recipe.key, version);
553
+
554
+ const known = new Set((tagsByKey.get(recipe.key) ?? []).map((tag) => tag.version));
555
+ for (const other of Object.keys(existing?.recipes[recipe.key]?.versions ?? {})) {
556
+ known.add(other);
496
557
  }
558
+ known.add(version);
559
+
560
+ const all = [...known];
561
+ published.set(
562
+ recipe.key,
563
+ publishing === undefined ? all.filter((other) => semver.lte(other, version)) : all
564
+ );
565
+ }
566
+
567
+ return { settled, published };
568
+ }
569
+
570
+ /**
571
+ * Turns one recipe's declared dependencies into the exact versions its index
572
+ * entry publishes.
573
+ *
574
+ * This is the whole point of recording dependencies in the index: a consumer
575
+ * installing version 1.4.0 of a recipe installs what 1.4.0 was released
576
+ * against, not whatever its ranges happen to reach months later. That is also
577
+ * why this runs only when a version is first recorded; an entry the index
578
+ * already publishes is carried forward and never resolved again.
579
+ *
580
+ * - A SIBLING with no range means "the version released alongside me", which
581
+ * is the version that sibling is settled on.
582
+ * - A SIBLING with a range resolves against the versions this repository
583
+ * publishes.
584
+ * - A CROSS-REPOSITORY dependency carries the identity of the repository it
585
+ * lives in, which is what a consumer needs in order to add that repository
586
+ * and find the recipe. Its exact version belongs to that repository's own
587
+ * index, so the range the manifest declared is recorded instead.
588
+ *
589
+ * @param declared - What the recipe's manifest declares.
590
+ * @param siblings - What each recipe here is published at, for this resolution.
591
+ */
592
+ function resolveIndexDependencies(
593
+ declared: DeclaredDependencies,
594
+ siblings: SiblingState
595
+ ): Record<string, IndexDependency> | undefined {
596
+ const resolved: Record<string, IndexDependency> = {};
597
+
598
+ for (const [key, dependency] of declared.byKey) {
599
+ if (dependency.kind === "remote") {
600
+ resolved[key] = { repo: dependency.repo, range: dependency.range };
601
+ continue;
602
+ }
603
+ if (dependency.range === undefined) {
604
+ const settled = siblings.settled.get(key);
605
+ resolved[key] = settled === undefined ? { range: "*" } : { version: settled };
606
+ continue;
607
+ }
608
+ const best = semver.maxSatisfying(siblings.published.get(key) ?? [], dependency.range);
609
+ resolved[key] = best === null ? { range: dependency.range } : { version: best };
497
610
  }
498
611
 
499
612
  return Object.keys(resolved).length === 0 ? undefined : resolved;
500
613
  }
501
614
 
615
+ /**
616
+ * Checks the dependencies the index records for an already published version
617
+ * against what that version's manifest declares.
618
+ *
619
+ * The recorded list is frozen, so it is never compared with a fresh
620
+ * resolution: a sibling released since, or a recipe a declared namespace has
621
+ * gained since, is exactly what freezing keeps out of it. What must still hold
622
+ * is that the list honours the manifest: every recipe the manifest names is
623
+ * there, nothing is there that the manifest does not declare, a sibling sits
624
+ * inside its declared range, and a dependency in another repository carries the
625
+ * repository and range the manifest wrote. A disagreement means the index was
626
+ * edited, or this sous reads the manifest differently from the one that
627
+ * published the version.
628
+ *
629
+ * @param key - The recipe's key, `namespace/recipe`.
630
+ * @param version - The published version.
631
+ * @param recorded - The dependencies the index records for it.
632
+ * @param declared - What the version's manifest declares.
633
+ */
634
+ function checkRecordedDependencies(
635
+ key: string,
636
+ version: string,
637
+ recorded: Record<string, IndexDependency> | undefined,
638
+ declared: DeclaredDependencies
639
+ ): ValidationProblem[] {
640
+ const differences: string[] = [];
641
+ const entries = recorded ?? {};
642
+
643
+ for (const [name, dependency] of declared.byKey) {
644
+ const entry = entries[name];
645
+ if (entry === undefined) {
646
+ if (dependency.kind === "remote" || dependency.named) {
647
+ differences.push(
648
+ `'${name}': the manifest declares ${describeDeclared(dependency)}, and the index ` +
649
+ `records nothing for it`
650
+ );
651
+ }
652
+ continue;
653
+ }
654
+ if (!recordedSatisfies(entry, dependency)) {
655
+ differences.push(
656
+ `'${name}': the index records ${describeRecorded(entry)}, and the manifest declares ` +
657
+ `${describeDeclared(dependency)}`
658
+ );
659
+ }
660
+ }
661
+
662
+ for (const [name, entry] of Object.entries(entries)) {
663
+ if (declared.byKey.has(name)) continue;
664
+ // A recipe the declared namespace held when the version was published, and
665
+ // no longer does, is history rather than a disagreement.
666
+ const namespace = name.slice(0, name.indexOf("/"));
667
+ if (entry.repo === undefined && declared.namespaces.has(namespace)) continue;
668
+ differences.push(
669
+ `'${name}': the index records ${describeRecorded(entry)}, and the manifest does not ` +
670
+ `declare it`
671
+ );
672
+ }
673
+
674
+ if (differences.length === 0) return [];
675
+ return [
676
+ {
677
+ level: "error",
678
+ where: `${INDEX_FILENAME} recipes['${key}'].versions['${version}'].dependencies`,
679
+ message:
680
+ `version ${version} of '${key}' is already published, and the dependencies the index ` +
681
+ `records for it disagree with what its manifest declares:\n` +
682
+ differences.map((line) => ` - ${line}\n`).join("") +
683
+ ` A published version never changes, so a release carries its dependencies forward ` +
684
+ `exactly as they were published. If the index was edited, restore the entry. To ` +
685
+ `publish different dependencies, bump the version in this manifest, or with ` +
686
+ `'sous repo release --bump patch'.`,
687
+ },
688
+ ];
689
+ }
690
+
691
+ /** True when a recorded dependency honours what the manifest declares for it. */
692
+ function recordedSatisfies(entry: IndexDependency, dependency: DeclaredDependency): boolean {
693
+ if (dependency.kind === "remote") {
694
+ return (
695
+ entry.repo === dependency.repo &&
696
+ entry.range === dependency.range &&
697
+ entry.version === undefined
698
+ );
699
+ }
700
+ if (entry.repo !== undefined) return false;
701
+ if (dependency.range === undefined) return true;
702
+ if (entry.version !== undefined) return semver.satisfies(entry.version, dependency.range);
703
+ return entry.range === dependency.range;
704
+ }
705
+
706
+ /** Describes a recorded dependency the way an author would say it. */
707
+ function describeRecorded(entry: IndexDependency): string {
708
+ const what =
709
+ entry.version !== undefined ? `version ${entry.version}` : `the range '${entry.range}'`;
710
+ return entry.repo === undefined ? what : `${what} from ${entry.repo}`;
711
+ }
712
+
713
+ /** Describes a declared dependency the way an author would say it. */
714
+ function describeDeclared(dependency: DeclaredDependency): string {
715
+ if (dependency.kind === "remote") {
716
+ return `the range '${dependency.range}' from ${dependency.repo}`;
717
+ }
718
+ return dependency.range === undefined ? "it with no range" : `the range '${dependency.range}'`;
719
+ }
720
+
721
+ /** What rebuilding one tagged version from its tag produced. */
722
+ type RebuiltVersion = {
723
+ /** The content hash of the recipe folder at the tag. */
724
+ hash: string;
725
+ /** The dependencies it was released against, when they could be worked out. */
726
+ dependencies?: Record<string, IndexDependency>;
727
+ /** A warning when they could not. */
728
+ problems: ValidationProblem[];
729
+ };
730
+
731
+ /**
732
+ * Rebuilds a tagged version the index does not record, from the tree its tag
733
+ * carries: the content hash of the recipe folder, and the dependencies its
734
+ * manifest declared there, resolved against the repository as it stood at the
735
+ * tag. That is as close as sous can get to what the release that cut the tag
736
+ * recorded; it is resolved once, then frozen like any other entry.
737
+ *
738
+ * @param rootDir - The repository's root directory.
739
+ * @param tag - The tag to rebuild from.
740
+ * @param recipe - The recipe as it stands now, for its folder and its key.
741
+ * @param tagsByKey - Every release tag, grouped by recipe.
742
+ * @param existing - The committed index, when there is one.
743
+ * @param run - The command runner git calls go through.
744
+ */
745
+ async function rebuildTaggedVersion(
746
+ rootDir: string,
747
+ tag: RecipeTag,
748
+ recipe: ValidatedRecipe,
749
+ tagsByKey: Map<string, RecipeTag[]>,
750
+ existing: IndexFile | undefined,
751
+ run: RunOptions["run"]
752
+ ): Promise<RebuiltVersion> {
753
+ return withTaggedTree(
754
+ rootDir,
755
+ tag.tag,
756
+ ".",
757
+ async (treeDir) => {
758
+ const hash = await hashDirectory(path.join(treeDir, ...recipe.path.split("/")));
759
+
760
+ let reason: string;
761
+ try {
762
+ const snapshot = validateRepo(treeDir);
763
+ const tagged = snapshot.recipes.find((entry) => entry.key === recipe.key);
764
+ if (tagged !== undefined) {
765
+ const dependencies = resolveIndexDependencies(
766
+ declaredDependencies(tagged, snapshot),
767
+ siblingState(snapshot, tagsByKey, existing)
768
+ );
769
+ return { hash, ...(dependencies === undefined ? {} : { dependencies }), problems: [] };
770
+ }
771
+ reason = `The repository manifest at that tag does not list '${recipe.key}'.`;
772
+ } catch (error) {
773
+ reason = describeError(error);
774
+ }
775
+
776
+ return {
777
+ hash,
778
+ problems: [
779
+ {
780
+ level: "warning",
781
+ where: `${INDEX_FILENAME} recipes['${recipe.key}'].versions['${tag.version}']`,
782
+ message:
783
+ `version ${tag.version} was rebuilt from the tag '${tag.tag}' without its ` +
784
+ `dependencies, because sous could not read the recipe at that tag.\n ${reason}`,
785
+ },
786
+ ],
787
+ };
788
+ },
789
+ { run }
790
+ );
791
+ }
792
+
502
793
  /** Groups release tags by the recipe key they belong to. */
503
794
  function groupTagsByKey(tags: ReadonlyArray<RecipeTag>): Map<string, RecipeTag[]> {
504
795
  const grouped = new Map<string, RecipeTag[]>();