@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.
- package/docs/markdown/_sidebar.md +1 -0
- package/docs/markdown/repositories-authoring.md +4 -3
- package/docs/markdown/repositories-file-formats.md +16 -0
- package/package.json +2 -2
- package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +1 -1
- package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +1 -1
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/src/lib/repos/formats/common.ts +18 -1
- package/src/lib/repos/formats/index-file.ts +79 -81
- package/src/lib/repos/release/index-builder.ts +371 -80
|
@@ -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
|
|
244
|
-
|
|
245
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
51
|
+
https://sous.io/markdown/#/configuration, which tracks the latest release;
|
|
52
52
|
prefer the on-disk copies.
|
|
53
53
|
|
|
54
54
|
## Rules
|
|
@@ -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
|
|
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 =
|
|
42
|
-
.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
|
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
|
|
468
|
+
function declaredDependencies(
|
|
445
469
|
recipe: ValidatedRecipe,
|
|
446
|
-
validation: RepoValidation
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[]>();
|