@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
|
@@ -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
|
|
16
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
30
|
-
*
|
|
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
|
-
/**
|
|
63
|
-
|
|
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 (
|
|
75
|
-
*
|
|
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(`
|
|
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
|
|
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
|
|
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 {
|
|
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);
|