@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.
@@ -12,25 +12,37 @@
12
12
  *
13
13
  * - a file inside a recipe may address only that recipe's own declared
14
14
  * dependencies (`depends` plus `subscribes`), at their pinned versions;
15
- * - a file in the project's own templates may address the project's
16
- * subscriptions.
15
+ * - a file in the project's own templates may address every recipe the
16
+ * lockfile pins, whatever holds it: the project, a set it subscribes to,
17
+ * or another recipe's `depends`.
18
+ *
19
+ * When a project template names a recipe the lockfile does not pin, the error
20
+ * says which recipe used to bring it in, when the cached indexes or the config
21
+ * can tell.
17
22
  *
18
23
  * A project with no lockfile entries gets no resolver at all, so a project that
19
24
  * uses no repositories behaves exactly as it did before: `~` in an include line
20
25
  * means an alias and nothing else.
21
26
  */
22
27
 
28
+ import semver from "semver";
23
29
  import type { Settings } from "../settings.js";
30
+ import { resolveStoreRoot } from "../sous-home.js";
24
31
  import {
25
32
  StaticNamespaceResolver,
33
+ normalizeRef,
34
+ type DroppedRecipe,
26
35
  type NamespaceResolver,
27
36
  } from "./namespace-resolver.js";
28
37
  import {
29
38
  listLockedRecipes,
30
- projectSubscriptionRefs,
39
+ readProjectLockfile,
31
40
  readRecipeManifestIn,
32
41
  type LockedRecipeLocation,
33
42
  } from "./locked-recipes.js";
43
+ import { createIndexCache } from "./providers/index.js";
44
+ import type { IndexFile } from "./formats/index-file.js";
45
+ import type { Lockfile } from "./formats/lockfile.js";
34
46
 
35
47
  /** How the project's namespace resolver is built. */
36
48
  export type ProjectNamespaceResolverOptions = {
@@ -82,9 +94,125 @@ export function createProjectNamespaceResolver(
82
94
  if (declared.length > 0) dependencies[recipe.key] = declared;
83
95
  }
84
96
 
97
+ // No `projectScope`: a project template may address every recipe the
98
+ // lockfile pins, and those are exactly the recipes this resolver knows.
85
99
  return new StaticNamespaceResolver({
86
100
  recipes,
87
101
  dependencies,
88
- projectScope: projectSubscriptionRefs(options.settings, locked),
102
+ explainMissing: (recipe) => explainUnpinnedRecipe(recipe, options),
89
103
  });
90
104
  }
105
+
106
+ /**
107
+ * Why the lockfile does not pin a recipe a project template asked for, when
108
+ * that can be known without the network. Two things are checked, in order:
109
+ *
110
+ * - a subscription to it (or to its namespace) that is switched off;
111
+ * - another published version of a pinned recipe that brought it in, read
112
+ * from the cached indexes of the lockfile's repositories. That version may
113
+ * declare it directly, or declare a recipe that no longer is pinned either
114
+ * and that declares it in turn; the shortest such chain is reported, and
115
+ * among the versions of one pinned recipe the newest wins. This is the
116
+ * usual story of a set that dropped one of its members, and the members'
117
+ * own libraries with it.
118
+ *
119
+ * Only ever runs while an error is being reported, so it reads the lockfile
120
+ * and the cached indexes then rather than on every build.
121
+ *
122
+ * @param recipe - The `namespace/recipe` the template asked for.
123
+ * @param options - The project's `.sous/` directory, its config and the environment.
124
+ */
125
+ export function explainUnpinnedRecipe(
126
+ recipe: string,
127
+ options: Pick<ProjectNamespaceResolverOptions, "sousDir" | "settings" | "env">
128
+ ): DroppedRecipe | undefined {
129
+ const namespace = recipe.slice(0, recipe.indexOf("/"));
130
+ for (const [key, entry] of Object.entries(options.settings.subscriptions ?? {})) {
131
+ if (entry?.enabled !== false) continue;
132
+ const ref = normalizeRef(key);
133
+ if (ref === recipe || ref === namespace) {
134
+ return { by: "disabled-subscription", subscription: key };
135
+ }
136
+ }
137
+
138
+ let lock: Lockfile;
139
+ try {
140
+ lock = readProjectLockfile(options.sousDir);
141
+ } catch {
142
+ // The build reports a broken lockfile on its own; an error message is no
143
+ // place to report it a second time.
144
+ return undefined;
145
+ }
146
+
147
+ const published = publishedVersions(lock, options.env ?? process.env);
148
+
149
+ /** What one published version of a recipe declares, as recipe keys. */
150
+ const declaredBy = (key: string, version: string): string[] =>
151
+ Object.keys(published.get(key)?.[version]?.dependencies ?? {});
152
+
153
+ // Breadth first from every other version of every pinned recipe, through
154
+ // recipes the lockfile does not pin, so the shortest chain is found first.
155
+ type Step = { key: string; root: { recipe: string; version: string }; through: string[] };
156
+ const queue: Step[] = [];
157
+ for (const holder of Object.keys(lock.recipes).sort()) {
158
+ const pinned = lock.recipes[holder]!.version;
159
+ const versions = Object.keys(published.get(holder) ?? {})
160
+ .filter((version) => version !== pinned && semver.valid(version) !== null)
161
+ .sort(semver.rcompare);
162
+ for (const version of versions) {
163
+ for (const key of declaredBy(holder, version)) {
164
+ queue.push({ key, root: { recipe: holder, version }, through: [] });
165
+ }
166
+ }
167
+ }
168
+
169
+ const seen = new Set<string>();
170
+ while (queue.length > 0) {
171
+ const step = queue.shift()!;
172
+ if (step.key === recipe) {
173
+ const root = step.root;
174
+ return {
175
+ by: "recipe",
176
+ recipe: root.recipe,
177
+ declaredAt: root.version,
178
+ pinned: lock.recipes[root.recipe]!.version,
179
+ ...(step.through.length > 0 ? { through: step.through } : {}),
180
+ };
181
+ }
182
+ // A pinned recipe is explained by its own entry, and each recipe is
183
+ // walked once.
184
+ if (Object.hasOwn(lock.recipes, step.key) || seen.has(step.key)) continue;
185
+ seen.add(step.key);
186
+ for (const version of Object.keys(published.get(step.key) ?? {})) {
187
+ for (const key of declaredBy(step.key, version)) {
188
+ queue.push({ key, root: step.root, through: [...step.through, step.key] });
189
+ }
190
+ }
191
+ }
192
+
193
+ return undefined;
194
+ }
195
+
196
+ /**
197
+ * Every published version of every recipe in the cached indexes of the
198
+ * repositories the lockfile names, keyed by recipe. A repository with no
199
+ * cached index contributes nothing.
200
+ *
201
+ * @param lock - The project's lockfile.
202
+ * @param env - The environment, which decides where the store is.
203
+ */
204
+ function publishedVersions(
205
+ lock: Lockfile,
206
+ env: NodeJS.ProcessEnv
207
+ ): Map<string, IndexFile["recipes"][string]["versions"]> {
208
+ const cache = createIndexCache({ storeRoot: resolveStoreRoot(env) });
209
+ const found = new Map<string, IndexFile["recipes"][string]["versions"]>();
210
+ const identities = new Set(Object.values(lock.repos).map((repo) => repo.identity));
211
+ for (const identity of [...identities].sort()) {
212
+ const index = cache.readCached(identity);
213
+ for (const [key, entry] of Object.entries(index?.recipes ?? {})) {
214
+ if (!found.has(key)) found.set(key, entry.versions);
215
+ }
216
+ }
217
+ return found;
218
+ }
@@ -20,7 +20,6 @@
20
20
 
21
21
  import fs from "node:fs";
22
22
  import path from "node:path";
23
- import type { Settings } from "../settings.js";
24
23
  import { resolveStoreRoot } from "../sous-home.js";
25
24
  import { parseLockfile, type LockKind, type Lockfile } from "./formats/lockfile.js";
26
25
  import { parseRecipeManifest, type RecipeManifest } from "./formats/recipe-manifest.js";
@@ -34,7 +33,6 @@ import {
34
33
  } from "./load-manifest.js";
35
34
  import { readEffectiveLinks } from "./links.js";
36
35
  import { identitySegments } from "./identity.js";
37
- import { enabledSubscriptions } from "./defaults.js";
38
36
  import { PROJECT_HOLDER } from "./formats/lockfile.js";
39
37
 
40
38
  /** One locked recipe, together with the directory its files are read from. */
@@ -233,26 +231,6 @@ export function keysHeldBySubscription(lock: Lockfile, key: string): string[] {
233
231
  .sort();
234
232
  }
235
233
 
236
- /**
237
- * The refs a project's own templates may address: everything it subscribed to
238
- * directly. Both sources are read, because a subscription may be written by
239
- * `sous subscribe` into the managed layer, hand-written in the primary config,
240
- * or (for an older project) recorded only in the lockfile.
241
- *
242
- * @param settings - The merged project config.
243
- * @param locked - The locked recipes, as returned by listLockedRecipes.
244
- */
245
- export function projectSubscriptionRefs(
246
- settings: Settings,
247
- locked: LockedRecipeLocation[]
248
- ): string[] {
249
- const refs = new Set<string>(Object.keys(enabledSubscriptions(settings)));
250
- for (const recipe of locked) {
251
- if (recipe.requestedBy.includes(PROJECT_HOLDER)) refs.add(recipe.key);
252
- }
253
- return [...refs].sort();
254
- }
255
-
256
234
  /**
257
235
  * Every locked recipe reachable from a set of keys through the lockfile's
258
236
  * holder lists: the keys themselves, whatever they hold, and so on.
@@ -26,10 +26,31 @@ import path from "node:path";
26
26
  * the including file:
27
27
  * - a file that lives inside a recipe may address only that recipe's declared
28
28
  * dependencies (`depends` plus `subscribes`) at their pinned versions;
29
- * - a file in the project's own templates may address the project's
30
- * subscriptions.
29
+ * - a file in the project's own templates may address every recipe the
30
+ * project's lockfile pins, whatever holds it.
31
31
  */
32
32
 
33
+ /**
34
+ * Why a recipe a project template asked for is not pinned any more, when that
35
+ * can be known. Only ever attached to an answer for a project template.
36
+ */
37
+ export type DroppedRecipe =
38
+ /**
39
+ * Another version of a pinned recipe declares it (in `depends` or
40
+ * `subscribes`), and the version the lockfile pins does not: the usual story
41
+ * of a set that dropped one of its members.
42
+ */
43
+ | {
44
+ by: "recipe";
45
+ recipe: string;
46
+ declaredAt: string;
47
+ pinned: string;
48
+ /** The recipes, no longer pinned either, that the chain ran through, in order. */
49
+ through?: string[];
50
+ }
51
+ /** The project subscribes to it, but the subscription is switched off. */
52
+ | { by: "disabled-subscription"; subscription: string };
53
+
33
54
  /** A single `~namespace/rest` resolution request. */
34
55
  export type NamespaceRequest = {
35
56
  /** The namespace name, with the leading `~` already stripped (e.g. `workflow`). */
@@ -59,20 +80,24 @@ export type NamespaceRequest = {
59
80
  export type NamespaceResolution =
60
81
  /** Ordered absolute paths to try, most preferred first. An empty list means the lookup produced nothing. */
61
82
  | { kind: "candidates"; candidates: string[] }
62
- /** No such namespace is known at all. `known` lists the namespaces that are. */
63
- | { kind: "unknown-namespace"; known: string[] }
83
+ /**
84
+ * No such namespace is known at all. `known` lists the namespaces that are.
85
+ * `dropped` says why a project template's recipe is no longer pinned, when
86
+ * that can be known.
87
+ */
88
+ | { kind: "unknown-namespace"; known: string[]; dropped?: DroppedRecipe }
64
89
  /**
65
90
  * The namespace exists but holds no such recipe. `recipe` is the fully
66
91
  * qualified ref that was asked for; `known` lists the recipe refs the
67
- * namespace does hold.
92
+ * namespace does hold; `dropped` is as above.
68
93
  */
69
- | { kind: "unknown-recipe"; recipe: string; known: string[] }
94
+ | { kind: "unknown-recipe"; recipe: string; known: string[]; dropped?: DroppedRecipe }
70
95
  /**
71
96
  * The recipe exists but the including file is not allowed to address it.
72
97
  * `recipe` is the fully qualified ref that was asked for. `includingRecipe`
73
98
  * is the ref of the recipe the including file belongs to, or `null` when the
74
- * including file is one of the project's own templates (in which case the
75
- * project simply does not subscribe to the recipe).
99
+ * including file is one of the project's own templates (which only happens
100
+ * when the resolver was given a narrower `projectScope`).
76
101
  */
77
102
  | { kind: "not-a-dependency"; recipe: string; includingRecipe: string | null }
78
103
  /**
@@ -126,6 +151,7 @@ export function formatNamespaceProblem(opts: {
126
151
  ? `Available namespaces: ${resolution.known.join(", ")}.`
127
152
  : "This project has no recipe namespaces available yet."
128
153
  );
154
+ if (resolution.dropped) lines.push(...describeDropped(resolution.dropped, opts));
129
155
  } else if (resolution.kind === "unknown-recipe") {
130
156
  lines.push(`recipe: ${resolution.recipe}`);
131
157
  lines.push(`The namespace "${opts.namespace}" holds no recipe named "${resolution.recipe}".`);
@@ -134,6 +160,7 @@ export function formatNamespaceProblem(opts: {
134
160
  ? `Recipes in this namespace: ${resolution.known.join(", ")}.`
135
161
  : `The namespace "${opts.namespace}" currently holds no recipes.`
136
162
  );
163
+ if (resolution.dropped) lines.push(...describeDropped(resolution.dropped, opts));
137
164
  } else if (resolution.kind === "not-a-dependency") {
138
165
  lines.push(`recipe: ${resolution.recipe}`);
139
166
  if (resolution.includingRecipe) {
@@ -144,10 +171,7 @@ export function formatNamespaceProblem(opts: {
144
171
  `Add "${resolution.recipe}" to the "depends" list in that recipe's manifest before addressing it as "~${opts.namespace}".`
145
172
  );
146
173
  } else {
147
- lines.push(`This project does not subscribe to "${resolution.recipe}".`);
148
- lines.push(
149
- `Subscribe to it before addressing it as "~${opts.namespace}" from a project template.`
150
- );
174
+ lines.push(`The project's own templates may not address "${resolution.recipe}".`);
151
175
  }
152
176
  } else if (resolution.kind === "escapes-recipe") {
153
177
  lines.push(`recipe: ${resolution.recipe}`);
@@ -167,6 +191,37 @@ export function formatNamespaceProblem(opts: {
167
191
  return lines.map((line) => ` ${line}`).join("\n");
168
192
  }
169
193
 
194
+ /**
195
+ * The lines that say why a recipe a project template asked for is no longer
196
+ * pinned, and what brings it back.
197
+ *
198
+ * @param dropped - What is known about how the recipe used to be pinned.
199
+ * @param opts - The namespace and the rest of the reference, for the recipe's name.
200
+ */
201
+ function describeDropped(
202
+ dropped: DroppedRecipe,
203
+ opts: { namespace: string; rest: string }
204
+ ): string[] {
205
+ const recipe = `${opts.namespace}/${opts.rest.split("/")[0] ?? ""}`;
206
+ if (dropped.by === "disabled-subscription") {
207
+ return [
208
+ `This project subscribes to "${dropped.subscription}", but that subscription is ` +
209
+ `switched off ("enabled: false"), so the lockfile does not pin "${recipe}".`,
210
+ `Switch the subscription back on, or remove the include.`,
211
+ ];
212
+ }
213
+ const through =
214
+ dropped.through === undefined || dropped.through.length === 0
215
+ ? ""
216
+ : ` (through ${dropped.through.map((key) => `"${key}"`).join(", ")})`;
217
+ return [
218
+ `Version ${dropped.declaredAt} of "${dropped.recipe}" brought "${recipe}" in${through}, but ` +
219
+ `the version this project pins, ${dropped.pinned}, does not, so the lockfile no longer ` +
220
+ `pins it.`,
221
+ `Subscribe to "${recipe}" directly to keep including it, or remove the include.`,
222
+ ];
223
+ }
224
+
170
225
  /** Options for {@link StaticNamespaceResolver}. */
171
226
  export type StaticNamespaceResolverOptions = {
172
227
  /**
@@ -186,9 +241,16 @@ export type StaticNamespaceResolverOptions = {
186
241
  /**
187
242
  * What the project's own templates may address, in the same ref forms as
188
243
  * `dependencies`. Omit it to make every known recipe addressable from
189
- * project templates (the convenient default for tests).
244
+ * project templates, which is the rule for a real project: its templates may
245
+ * address everything its lockfile pins.
190
246
  */
191
247
  projectScope?: string[];
248
+ /**
249
+ * Says why a recipe a project template asked for is not among `recipes`,
250
+ * when that can be known. Consulted only for a project template's reference
251
+ * to a recipe the resolver does not know.
252
+ */
253
+ explainMissing?: (recipe: string) => DroppedRecipe | undefined;
192
254
  };
193
255
 
194
256
  /**
@@ -196,7 +258,7 @@ export type StaticNamespaceResolverOptions = {
196
258
  * recipe refs to directories.
197
259
  *
198
260
  * It implements the full scoping rule (recipe files see their declared
199
- * dependencies; project files see the project's subscriptions) without knowing
261
+ * dependencies; project files see every known recipe, or `projectScope`) without knowing
200
262
  * anything about repositories, versions or the store, which makes it the
201
263
  * resolver used by tests and a usable core for the real implementation to wrap.
202
264
  */
@@ -204,6 +266,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
204
266
  private readonly recipes: Record<string, string>;
205
267
  private readonly dependencies: Record<string, string[]>;
206
268
  private readonly projectScope?: string[];
269
+ private readonly explainMissing?: (recipe: string) => DroppedRecipe | undefined;
207
270
 
208
271
  constructor(options: StaticNamespaceResolverOptions) {
209
272
  this.recipes = {};
@@ -212,6 +275,18 @@ export class StaticNamespaceResolver implements NamespaceResolver {
212
275
  }
213
276
  this.dependencies = options.dependencies ?? {};
214
277
  this.projectScope = options.projectScope;
278
+ this.explainMissing = options.explainMissing;
279
+ }
280
+
281
+ /**
282
+ * Why a project template's reference names a recipe this resolver does not
283
+ * know, or undefined when the including file is a recipe's own or nothing is
284
+ * known.
285
+ */
286
+ private droppedFor(recipe: string, fromFile: string): { dropped?: DroppedRecipe } {
287
+ if (this.explainMissing === undefined || this.includingRecipe(fromFile) !== null) return {};
288
+ const dropped = this.explainMissing(recipe);
289
+ return dropped === undefined ? {} : { dropped };
215
290
  }
216
291
 
217
292
  /** Every namespace this resolver knows about, sorted. */
@@ -254,17 +329,27 @@ export class StaticNamespaceResolver implements NamespaceResolver {
254
329
  resolve(request: NamespaceRequest): NamespaceResolution {
255
330
  const { namespace, rest, fromFile } = request;
256
331
 
257
- if (!this.knownNamespaces().includes(namespace)) {
258
- return { kind: "unknown-namespace", known: this.knownNamespaces() };
259
- }
260
-
261
332
  const segments = rest.split("/").filter((segment) => segment.length > 0);
262
333
  const recipeName = segments[0] ?? "";
263
334
  const ref = `${namespace}/${recipeName}`;
335
+
336
+ if (!this.knownNamespaces().includes(namespace)) {
337
+ return {
338
+ kind: "unknown-namespace",
339
+ known: this.knownNamespaces(),
340
+ ...this.droppedFor(ref, fromFile),
341
+ };
342
+ }
343
+
264
344
  const recipeDir = this.recipes[ref];
265
345
 
266
346
  if (!recipeDir) {
267
- return { kind: "unknown-recipe", recipe: ref, known: this.recipesIn(namespace) };
347
+ return {
348
+ kind: "unknown-recipe",
349
+ recipe: ref,
350
+ known: this.recipesIn(namespace),
351
+ ...this.droppedFor(ref, fromFile),
352
+ };
268
353
  }
269
354
 
270
355
  const includingRecipe = this.includingRecipe(fromFile);