@sous-io/sous 0.2.25 → 0.2.27
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 +2 -0
- package/docs/markdown/commands.md +24 -8
- package/docs/markdown/repositories-authoring.md +5 -4
- package/docs/markdown/repositories-file-formats.md +16 -0
- package/docs/markdown/repositories.md +20 -3
- package/package.json +1 -1
- package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +3 -2
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/src/commands/build.ts +28 -43
- package/src/commands/compile.ts +2 -2
- package/src/commands/init.ts +9 -17
- package/src/commands/launch.ts +13 -7
- package/src/commands/prune.ts +13 -10
- package/src/commands/repo/remove.ts +7 -6
- package/src/commands/repo/unlink.ts +7 -6
- package/src/commands/subscription/add.ts +7 -6
- package/src/commands/subscription/remove.ts +7 -6
- package/src/commands/subscription/update.ts +7 -6
- package/src/lib/build-preparation.ts +3 -4
- package/src/lib/build-service.ts +60 -16
- package/src/lib/markdown-compiler.ts +75 -15
- package/src/lib/repos/formats/common.ts +18 -1
- package/src/lib/repos/formats/index-file.ts +79 -81
- package/src/lib/repos/locked-namespace-resolver.ts +132 -4
- package/src/lib/repos/locked-recipes.ts +0 -22
- package/src/lib/repos/namespace-resolver.ts +104 -19
- package/src/lib/repos/release/index-builder.ts +371 -80
|
@@ -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[]>();
|