@sous-io/sous 0.2.32 → 0.2.33
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/README.md +7 -6
- package/docs/markdown/_sidebar.md +1 -0
- package/docs/markdown/commands.md +8 -4
- package/docs/markdown/configuration.md +28 -4
- package/docs/markdown/repositories-authoring.md +22 -2
- package/docs/markdown/repositories-consuming.md +40 -11
- package/docs/markdown/repositories-file-formats.md +28 -13
- package/docs/markdown/repositories-quickstart.md +1 -1
- package/docs/markdown/repositories.md +8 -4
- package/package.json +2 -1
- package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +25 -17
- package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +4 -0
- package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +21 -9
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/sous.config.schema.json +27 -15
- package/src/commands/lock/rebuild.ts +3 -6
- package/src/commands/namespace/show.ts +2 -2
- package/src/commands/recipe/show.ts +26 -9
- package/src/commands/repo/contribute.ts +32 -23
- package/src/commands/repo/gc.ts +1 -1
- package/src/commands/repo/remove.ts +1 -1
- package/src/commands/repo/search.ts +1 -1
- package/src/commands/vars/ask.ts +75 -54
- package/src/lib/build-service.ts +85 -11
- package/src/lib/config-schema.ts +59 -19
- package/src/lib/include-resolver.ts +307 -69
- package/src/lib/markdown-compiler.ts +273 -81
- package/src/lib/project-scaffold/templates.ts +19 -11
- package/src/lib/repos/catalog-inputs.ts +1 -1
- package/src/lib/repos/catalog.ts +98 -72
- package/src/lib/repos/declarations.ts +22 -12
- package/src/lib/repos/formats/index-file.ts +1 -1
- package/src/lib/repos/formats/recipe-manifest.ts +3 -4
- package/src/lib/repos/lock-service.ts +1 -1
- package/src/lib/repos/locked-namespace-resolver.ts +14 -6
- package/src/lib/repos/locked-recipe-lookup.ts +123 -0
- package/src/lib/repos/locked-recipes.ts +1 -1
- package/src/lib/repos/memories-hash-name.ts +33 -0
- package/src/lib/repos/namespace-resolver.ts +198 -119
- package/src/lib/repos/recipe-config-layers.ts +29 -1
- package/src/lib/repos/recipe-key-matcher.ts +62 -0
- package/src/lib/repos/recipe-memories.ts +175 -0
- package/src/lib/repos/recipe-targets.ts +27 -28
- package/src/lib/repos/ref-search.ts +1 -1
- package/src/lib/repos/release/index-builder.ts +23 -15
- package/src/lib/repos/release/plan.ts +14 -7
- package/src/lib/repos/release/settle.ts +38 -121
- package/src/lib/repos/release/submit-checkout.ts +42 -23
- package/src/lib/repos/release/tags.ts +1 -1
- package/src/lib/repos/resolver.ts +90 -72
- package/src/lib/repos/subscription-service.ts +192 -170
- package/src/lib/settings.ts +37 -33
- package/src/lib/vars/ask.ts +3 -3
- package/src/lib/vars/mappings.ts +17 -9
- package/src/services/ref-resolver/container.ts +107 -0
- package/src/services/ref-resolver/dependency.ts +60 -0
- package/src/services/ref-resolver/format.ts +197 -0
- package/src/services/ref-resolver/glob.ts +115 -0
- package/src/services/ref-resolver/hash-names.ts +155 -0
- package/src/services/ref-resolver/index.ts +55 -0
- package/src/services/ref-resolver/injectable.ts +36 -0
- package/src/services/ref-resolver/location.ts +62 -0
- package/src/services/ref-resolver/lookups/cached-index-lookup.ts +53 -0
- package/src/services/ref-resolver/lookups/catalog-lookup.ts +23 -0
- package/src/services/ref-resolver/lookups/catalog-matcher.ts +261 -0
- package/src/services/ref-resolver/lookups/chained-lookup.ts +27 -0
- package/src/services/ref-resolver/lookups/env-var-lookup.ts +65 -0
- package/src/services/ref-resolver/lookups/fetched-index-lookup.ts +150 -0
- package/src/services/ref-resolver/lookups/recorded-dependency-lookup.ts +62 -0
- package/src/services/ref-resolver/lookups/ref-lookup.ts +54 -0
- package/src/services/ref-resolver/lookups/variable-lookup.ts +123 -0
- package/src/services/ref-resolver/named-refs.ts +144 -0
- package/src/services/ref-resolver/parser/env-var-splitter.ts +27 -0
- package/src/services/ref-resolver/parser/location-splitter.ts +239 -0
- package/src/services/ref-resolver/parser/name-path-splitter.ts +200 -0
- package/src/services/ref-resolver/parser/partial-ref.ts +84 -0
- package/src/services/ref-resolver/parser/query-splitter.ts +61 -0
- package/src/services/ref-resolver/parser/range-splitter.ts +52 -0
- package/src/services/ref-resolver/parser/ref-parser.ts +115 -0
- package/src/services/ref-resolver/parser/repo-qualifier-splitter.ts +51 -0
- package/src/services/ref-resolver/parts.ts +115 -0
- package/src/services/ref-resolver/pruners/command-line-pruner.ts +47 -0
- package/src/services/ref-resolver/pruners/include-pruner.ts +66 -0
- package/src/services/ref-resolver/pruners/manifest-pruner.ts +130 -0
- package/src/services/ref-resolver/pruners/ref-pruner.ts +135 -0
- package/src/services/ref-resolver/pruners/rule-helpers.ts +44 -0
- package/src/services/ref-resolver/pruners/stored-key-pruner.ts +136 -0
- package/src/services/ref-resolver/ref-helpers.ts +62 -0
- package/src/services/ref-resolver/ref-picker-service.ts +204 -0
- package/src/{lib/repos/reference-report.ts → services/ref-resolver/ref-report.ts} +2 -40
- package/src/services/ref-resolver/ref-resolve-arguments.ts +47 -0
- package/src/services/ref-resolver/ref-resolve-result.ts +36 -0
- package/src/services/ref-resolver/ref-resolver-service.ts +323 -0
- package/src/services/ref-resolver/source.ts +48 -0
- package/src/services/ref-resolver/tokens.ts +25 -0
- package/src/services/ref-resolver/types.ts +155 -0
- package/src/templating/init-liquid-engine.ts +25 -18
- package/src/lib/refs/find.ts +0 -770
- package/src/lib/refs/index.ts +0 -14
- package/src/lib/refs/parse.ts +0 -844
- package/src/lib/refs/pick.ts +0 -147
- package/src/lib/refs/scopes.ts +0 -95
- package/src/lib/refs/settle.ts +0 -219
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import { escape as escapeGlob } from "glob";
|
|
3
|
+
import { braceExpand } from "minimatch";
|
|
4
|
+
import {
|
|
5
|
+
RefResolveArguments,
|
|
6
|
+
RefSource,
|
|
7
|
+
namespaceOfKey,
|
|
8
|
+
splitRecipeKey,
|
|
9
|
+
sharedRefResolver,
|
|
10
|
+
splitSegments,
|
|
11
|
+
type RecipeFileRef,
|
|
12
|
+
} from "../../services/ref-resolver/index.js";
|
|
13
|
+
import { LockedRecipeFileLookup } from "./locked-recipe-lookup.js";
|
|
3
14
|
|
|
4
15
|
/**
|
|
5
16
|
* Namespace addressability for templates: the reserved `~` include sigil.
|
|
6
17
|
*
|
|
7
|
-
* An include line of the form `@~<namespace>/<
|
|
8
|
-
* `{% render "~<namespace>/<
|
|
9
|
-
* than the filesystem.
|
|
10
|
-
* the
|
|
18
|
+
* An include line of the form `@~<namespace>/<recipe>/<path>` (and the
|
|
19
|
+
* equivalent `{% render "~<namespace>/<recipe>/<path>" %}`) addresses a recipe
|
|
20
|
+
* rather than the filesystem. The reference is read by the ref resolver service
|
|
21
|
+
* (`RefSource.Include`) and matched against the recipes the project pins, so any
|
|
22
|
+
* form that service allows works: a glob in any name or in the path, a `repo:`
|
|
23
|
+
* qualifier, a name in any case. For example
|
|
11
24
|
*
|
|
12
25
|
* @~workflow/task-files/_partials/resume.md
|
|
13
26
|
*
|
|
@@ -52,16 +65,17 @@ export type DroppedRecipe =
|
|
|
52
65
|
/** The project subscribes to it, but the subscription is switched off. */
|
|
53
66
|
| { by: "disabled-subscription"; subscription: string };
|
|
54
67
|
|
|
55
|
-
/** A
|
|
68
|
+
/** A recipe a candidate belongs to: its key and its directory. */
|
|
69
|
+
export type RecipeRoot = { recipe: string; dir: string };
|
|
70
|
+
|
|
71
|
+
/** A single `~` reference resolution request. */
|
|
56
72
|
export type NamespaceRequest = {
|
|
57
|
-
/** The namespace name, with the leading `~` already stripped (e.g. `workflow`). */
|
|
58
|
-
namespace: string;
|
|
59
73
|
/**
|
|
60
|
-
*
|
|
61
|
-
* inside
|
|
62
|
-
*
|
|
74
|
+
* The reference with its `~` sigil taken off: namespace, recipe and the path
|
|
75
|
+
* inside the recipe (e.g. `workflow/task-files/_partials/resume.md`), as
|
|
76
|
+
* written, possibly with a `repo:` qualifier, globs and a `?name=value` query.
|
|
63
77
|
*/
|
|
64
|
-
|
|
78
|
+
reference: string;
|
|
65
79
|
/**
|
|
66
80
|
* Absolute path of the file performing the include, used to decide which
|
|
67
81
|
* recipe (if any) is asking. A directory path is accepted for callers that
|
|
@@ -79,14 +93,23 @@ export type NamespaceRequest = {
|
|
|
79
93
|
* grow without breaking older callers.
|
|
80
94
|
*/
|
|
81
95
|
export type NamespaceResolution =
|
|
82
|
-
/**
|
|
83
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Ordered absolute paths to try, most preferred first. An empty list means
|
|
98
|
+
* the lookup produced nothing. When `glob` is true the reference was a glob:
|
|
99
|
+
* every candidate is a pattern (the recipe's own directory escaped), and ALL
|
|
100
|
+
* of them are included, in order, not just the first that exists.
|
|
101
|
+
* `roots`, when present, holds the recipe (and its directory) each candidate belongs to
|
|
102
|
+
* (same length and order); a caller that reads files refuses any file whose
|
|
103
|
+
* real path is outside its root, which is how a symlink inside a recipe cannot
|
|
104
|
+
* lead out of it.
|
|
105
|
+
*/
|
|
106
|
+
| { kind: "candidates"; candidates: string[]; glob?: true; roots?: RecipeRoot[] }
|
|
84
107
|
/**
|
|
85
108
|
* No such namespace is known at all. `known` lists the namespaces that are.
|
|
86
109
|
* `dropped` says why a project template's recipe is no longer pinned, when
|
|
87
110
|
* that can be known.
|
|
88
111
|
*/
|
|
89
|
-
| { kind: "unknown-namespace"; known: string[]; dropped?: DroppedRecipe }
|
|
112
|
+
| { kind: "unknown-namespace"; namespace: string; recipe: string; known: string[]; dropped?: DroppedRecipe }
|
|
90
113
|
/**
|
|
91
114
|
* The namespace exists but holds no such recipe. `recipe` is the fully
|
|
92
115
|
* qualified ref that was asked for; `known` lists the recipe refs the
|
|
@@ -107,17 +130,32 @@ export type NamespaceResolution =
|
|
|
107
130
|
* recipe's own files and nothing else, so this is refused rather than
|
|
108
131
|
* resolved. `reference` is the reference as it was written.
|
|
109
132
|
*/
|
|
110
|
-
| { kind: "escapes-recipe"; recipe: string; reference: string }
|
|
133
|
+
| { kind: "escapes-recipe"; recipe: string; reference: string }
|
|
134
|
+
/**
|
|
135
|
+
* The reference is not one an include line may hold (it names no file inside
|
|
136
|
+
* a recipe, carries a version range, and so on). `message` is the ref
|
|
137
|
+
* service's own sentence, which says what to write instead.
|
|
138
|
+
*/
|
|
139
|
+
| { kind: "invalid"; reference: string; message: string };
|
|
111
140
|
|
|
112
|
-
/** Resolves
|
|
141
|
+
/** Resolves `~` references to candidate absolute paths. */
|
|
113
142
|
export interface NamespaceResolver {
|
|
114
143
|
/**
|
|
115
|
-
* Resolve one
|
|
144
|
+
* Resolve one `~` reference.
|
|
116
145
|
*
|
|
117
|
-
* @param request - The
|
|
146
|
+
* @param request - The reference and the including file.
|
|
118
147
|
* @returns Candidate absolute paths (most preferred first), or a reason the lookup failed.
|
|
119
148
|
*/
|
|
120
149
|
resolve(request: NamespaceRequest): NamespaceResolution;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The ref of the recipe whose directory holds `fromFile`, or null when the
|
|
153
|
+
* file is not inside any recipe (a project template). Optional: a resolver
|
|
154
|
+
* that cannot tell is treated as one for which every file is the project's.
|
|
155
|
+
*
|
|
156
|
+
* @param fromFile - Absolute path of a file or directory.
|
|
157
|
+
*/
|
|
158
|
+
includingRecipe?(fromFile: string): string | null;
|
|
121
159
|
}
|
|
122
160
|
|
|
123
161
|
/**
|
|
@@ -125,43 +163,49 @@ export interface NamespaceResolver {
|
|
|
125
163
|
* message. Each line is indented by two spaces so it can be appended directly
|
|
126
164
|
* to the compiler's "Include not found" block.
|
|
127
165
|
*
|
|
128
|
-
* @param opts.namespace - The namespace that was asked for.
|
|
129
|
-
* @param opts.rest - The remainder of the reference (recipe name plus inner path).
|
|
130
166
|
* @param opts.fromFile - The file (or directory) that performed the include.
|
|
131
167
|
* @param opts.resolution - What the resolver returned.
|
|
132
168
|
* @returns Indented, newline-joined explanation lines; an empty string when there is nothing to add.
|
|
133
169
|
*/
|
|
134
170
|
export function formatNamespaceProblem(opts: {
|
|
135
|
-
namespace: string;
|
|
136
|
-
rest: string;
|
|
137
171
|
fromFile: string;
|
|
138
172
|
resolution: NamespaceResolution;
|
|
139
173
|
}): string {
|
|
140
|
-
const lines: string[] = [`in file: ${opts.fromFile}`, `namespace: ${opts.namespace}`];
|
|
141
|
-
|
|
142
174
|
const resolution = opts.resolution;
|
|
143
175
|
|
|
144
176
|
if (resolution.kind === "candidates") {
|
|
145
177
|
return "";
|
|
146
178
|
}
|
|
147
179
|
|
|
180
|
+
if (resolution.kind === "invalid") {
|
|
181
|
+
return [`in file: ${opts.fromFile}`, `reference: ~${resolution.reference}`, ...resolution.message.split("\n")]
|
|
182
|
+
.map((line) => ` ${line}`)
|
|
183
|
+
.join("\n");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const namespace =
|
|
187
|
+
resolution.kind === "unknown-namespace"
|
|
188
|
+
? resolution.namespace
|
|
189
|
+
: namespaceOfKey(resolution.recipe);
|
|
190
|
+
const lines: string[] = [`in file: ${opts.fromFile}`, `namespace: ${namespace}`];
|
|
191
|
+
|
|
148
192
|
if (resolution.kind === "unknown-namespace") {
|
|
149
|
-
lines.push(`There is no recipe namespace named "${
|
|
193
|
+
lines.push(`There is no recipe namespace named "${namespace}" available here.`);
|
|
150
194
|
lines.push(
|
|
151
195
|
resolution.known.length > 0
|
|
152
196
|
? `Available namespaces: ${resolution.known.join(", ")}.`
|
|
153
197
|
: "This project has no recipe namespaces available yet."
|
|
154
198
|
);
|
|
155
|
-
if (resolution.dropped) lines.push(...describeDropped(resolution.dropped,
|
|
199
|
+
if (resolution.dropped) lines.push(...describeDropped(resolution.dropped, resolution.recipe));
|
|
156
200
|
} else if (resolution.kind === "unknown-recipe") {
|
|
157
201
|
lines.push(`recipe: ${resolution.recipe}`);
|
|
158
|
-
lines.push(`The namespace "${
|
|
202
|
+
lines.push(`The namespace "${namespace}" holds no recipe named "${resolution.recipe}".`);
|
|
159
203
|
lines.push(
|
|
160
204
|
resolution.known.length > 0
|
|
161
205
|
? `Recipes in this namespace: ${resolution.known.join(", ")}.`
|
|
162
|
-
: `The namespace "${
|
|
206
|
+
: `The namespace "${namespace}" currently holds no recipes.`
|
|
163
207
|
);
|
|
164
|
-
if (resolution.dropped) lines.push(...describeDropped(resolution.dropped,
|
|
208
|
+
if (resolution.dropped) lines.push(...describeDropped(resolution.dropped, resolution.recipe));
|
|
165
209
|
} else if (resolution.kind === "not-a-dependency") {
|
|
166
210
|
lines.push(`recipe: ${resolution.recipe}`);
|
|
167
211
|
if (resolution.includingRecipe) {
|
|
@@ -169,7 +213,7 @@ export function formatNamespaceProblem(opts: {
|
|
|
169
213
|
`The recipe "${resolution.includingRecipe}" does not declare "${resolution.recipe}" as a dependency.`
|
|
170
214
|
);
|
|
171
215
|
lines.push(
|
|
172
|
-
`Add "${resolution.recipe}" to the "depends" list in that recipe's manifest before addressing it as "~${
|
|
216
|
+
`Add "${resolution.recipe}" to the "depends" list in that recipe's manifest before addressing it as "~${namespace}".`
|
|
173
217
|
);
|
|
174
218
|
} else {
|
|
175
219
|
lines.push(`The project's own templates may not address "${resolution.recipe}".`);
|
|
@@ -181,7 +225,8 @@ export function formatNamespaceProblem(opts: {
|
|
|
181
225
|
);
|
|
182
226
|
lines.push(
|
|
183
227
|
`A "~namespace" reference addresses a recipe's own files, so it may not contain ` +
|
|
184
|
-
`"." or ".." segments
|
|
228
|
+
`"." or ".." segments (also inside a { } alternative), may not be an absolute path, ` +
|
|
229
|
+
`and may not reach a file through a link that leaves the recipe.`
|
|
185
230
|
);
|
|
186
231
|
lines.push(
|
|
187
232
|
`Write the path of a file inside the recipe, or include the other file by a ` +
|
|
@@ -197,13 +242,9 @@ export function formatNamespaceProblem(opts: {
|
|
|
197
242
|
* pinned, and what brings it back.
|
|
198
243
|
*
|
|
199
244
|
* @param dropped - What is known about how the recipe used to be pinned.
|
|
200
|
-
* @param
|
|
245
|
+
* @param recipe - The `namespace/recipe` the template asked for.
|
|
201
246
|
*/
|
|
202
|
-
function describeDropped(
|
|
203
|
-
dropped: DroppedRecipe,
|
|
204
|
-
opts: { namespace: string; rest: string }
|
|
205
|
-
): string[] {
|
|
206
|
-
const { recipe } = splitIncludePath(opts.namespace, opts.rest);
|
|
247
|
+
function describeDropped(dropped: DroppedRecipe, recipe: string): string[] {
|
|
207
248
|
if (dropped.by === "disabled-subscription") {
|
|
208
249
|
return [
|
|
209
250
|
`This project subscribes to "${dropped.subscription}", but that subscription is ` +
|
|
@@ -253,6 +294,12 @@ export type StaticNamespaceResolverOptions = {
|
|
|
253
294
|
* to a recipe the resolver does not know.
|
|
254
295
|
*/
|
|
255
296
|
explainMissing?: (recipe: string) => DroppedRecipe | undefined;
|
|
297
|
+
/**
|
|
298
|
+
* The short name of the repository each recipe came from, keyed like
|
|
299
|
+
* `recipes`. It is what a `repo:` qualifier in an include line is matched
|
|
300
|
+
* against; a recipe with no entry matches no qualifier.
|
|
301
|
+
*/
|
|
302
|
+
repos?: Record<string, string>;
|
|
256
303
|
};
|
|
257
304
|
|
|
258
305
|
/**
|
|
@@ -269,6 +316,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
|
|
|
269
316
|
private readonly dependencies: Record<string, string[]>;
|
|
270
317
|
private readonly projectScope?: string[];
|
|
271
318
|
private readonly explainMissing?: (recipe: string) => DroppedRecipe | undefined;
|
|
319
|
+
private readonly lookup: LockedRecipeFileLookup;
|
|
272
320
|
|
|
273
321
|
constructor(options: StaticNamespaceResolverOptions) {
|
|
274
322
|
this.recipes = {};
|
|
@@ -278,6 +326,17 @@ export class StaticNamespaceResolver implements NamespaceResolver {
|
|
|
278
326
|
this.dependencies = options.dependencies ?? {};
|
|
279
327
|
this.projectScope = options.projectScope;
|
|
280
328
|
this.explainMissing = options.explainMissing;
|
|
329
|
+
this.lookup = new LockedRecipeFileLookup(
|
|
330
|
+
Object.keys(this.recipes).map((key) => {
|
|
331
|
+
const recipe = splitRecipeKey(key);
|
|
332
|
+
const repo = options.repos?.[key];
|
|
333
|
+
return {
|
|
334
|
+
namespace: recipe.namespace,
|
|
335
|
+
name: recipe.name ?? "",
|
|
336
|
+
...(repo === undefined ? {} : { repo }),
|
|
337
|
+
};
|
|
338
|
+
})
|
|
339
|
+
);
|
|
281
340
|
}
|
|
282
341
|
|
|
283
342
|
/**
|
|
@@ -291,28 +350,12 @@ export class StaticNamespaceResolver implements NamespaceResolver {
|
|
|
291
350
|
return dropped === undefined ? {} : { dropped };
|
|
292
351
|
}
|
|
293
352
|
|
|
294
|
-
/** Every namespace this resolver knows about, sorted. */
|
|
295
|
-
private knownNamespaces(): string[] {
|
|
296
|
-
const names = new Set<string>();
|
|
297
|
-
for (const ref of Object.keys(this.recipes)) {
|
|
298
|
-
names.add(namespaceOfKey(ref));
|
|
299
|
-
}
|
|
300
|
-
return [...names].sort();
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/** Every known recipe ref inside one namespace, sorted. */
|
|
304
|
-
private recipesIn(namespace: string): string[] {
|
|
305
|
-
return Object.keys(this.recipes)
|
|
306
|
-
.filter((ref) => namespaceOfKey(ref) === namespace)
|
|
307
|
-
.sort();
|
|
308
|
-
}
|
|
309
|
-
|
|
310
353
|
/**
|
|
311
354
|
* The ref of the recipe whose directory contains `fromFile`, or null when the
|
|
312
355
|
* file lives outside every recipe (a project template). The deepest matching
|
|
313
356
|
* recipe directory wins, so nested layouts resolve to the innermost recipe.
|
|
314
357
|
*/
|
|
315
|
-
|
|
358
|
+
includingRecipe(fromFile: string): string | null {
|
|
316
359
|
const target = path.resolve(fromFile);
|
|
317
360
|
let best: string | null = null;
|
|
318
361
|
let bestLength = -1;
|
|
@@ -328,27 +371,72 @@ export class StaticNamespaceResolver implements NamespaceResolver {
|
|
|
328
371
|
return best;
|
|
329
372
|
}
|
|
330
373
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
374
|
+
/**
|
|
375
|
+
* The answer for a reference the ref service refused: a path that climbs out
|
|
376
|
+
* of the recipe is `escapes-recipe`, and anything else is `invalid`, with the
|
|
377
|
+
* service's own sentence.
|
|
378
|
+
*/
|
|
379
|
+
private refused(reference: string, error: unknown): NamespaceResolution {
|
|
380
|
+
const resolver = sharedRefResolver();
|
|
381
|
+
try {
|
|
382
|
+
const { dropped } = resolver.inspect(reference, RefSource.Include);
|
|
383
|
+
for (const entry of dropped) {
|
|
384
|
+
if (entry.ref.kind !== "recipeFile") continue;
|
|
385
|
+
if (splitSegments(entry.ref.path).some((part) => part === "." || part === "..")) {
|
|
386
|
+
return {
|
|
387
|
+
kind: "escapes-recipe",
|
|
388
|
+
recipe: `${entry.ref.recipe.namespace?.name ?? ""}/${entry.ref.recipe.name}`,
|
|
389
|
+
reference,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
} catch {
|
|
394
|
+
// Not a ref in any reading; the original refusal says why.
|
|
342
395
|
}
|
|
396
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
397
|
+
return { kind: "invalid", reference, message };
|
|
398
|
+
}
|
|
343
399
|
|
|
344
|
-
|
|
400
|
+
resolve(request: NamespaceRequest): NamespaceResolution {
|
|
401
|
+
const { reference, fromFile } = request;
|
|
402
|
+
const resolver = sharedRefResolver();
|
|
403
|
+
|
|
404
|
+
let result;
|
|
405
|
+
try {
|
|
406
|
+
result = resolver.resolveSync(new RefResolveArguments({
|
|
407
|
+
input: reference,
|
|
408
|
+
from: RefSource.Include,
|
|
409
|
+
lookup: this.lookup,
|
|
410
|
+
kinds: ["recipeFile"],
|
|
411
|
+
}));
|
|
412
|
+
} catch (error) {
|
|
413
|
+
return this.refused(reference, error);
|
|
414
|
+
}
|
|
345
415
|
|
|
346
|
-
|
|
416
|
+
// The reading the caller meant: of a `?name=value` that could also be part
|
|
417
|
+
// of a glob, the one that read it as values comes first.
|
|
418
|
+
const { kept } = resolver.inspect(reference, RefSource.Include);
|
|
419
|
+
const written = (kept.find((ref) => ref.vars !== undefined) ?? kept[0]) as RecipeFileRef;
|
|
420
|
+
const namespaceName = written.recipe.namespace?.name ?? "";
|
|
421
|
+
const wanted = `${namespaceName}/${written.recipe.name}`;
|
|
422
|
+
|
|
423
|
+
const matched = result.refs.filter((ref): ref is RecipeFileRef => ref.kind === "recipeFile");
|
|
424
|
+
if (matched.length === 0) {
|
|
425
|
+
if (written.glob === true) return { kind: "candidates", candidates: [], glob: true };
|
|
426
|
+
if (!this.lookup.knowsNamespace(namespaceName)) {
|
|
427
|
+
return {
|
|
428
|
+
kind: "unknown-namespace",
|
|
429
|
+
namespace: namespaceName,
|
|
430
|
+
recipe: wanted,
|
|
431
|
+
known: this.lookup.namespaces(),
|
|
432
|
+
...this.droppedFor(wanted, fromFile),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
347
435
|
return {
|
|
348
436
|
kind: "unknown-recipe",
|
|
349
|
-
recipe:
|
|
350
|
-
known: this.
|
|
351
|
-
...this.droppedFor(
|
|
437
|
+
recipe: wanted,
|
|
438
|
+
known: this.lookup.recipesInNamespace(namespaceName),
|
|
439
|
+
...this.droppedFor(wanted, fromFile),
|
|
352
440
|
};
|
|
353
441
|
}
|
|
354
442
|
|
|
@@ -357,66 +445,57 @@ export class StaticNamespaceResolver implements NamespaceResolver {
|
|
|
357
445
|
? [includingRecipe, ...(this.dependencies[includingRecipe] ?? [])]
|
|
358
446
|
: this.projectScope;
|
|
359
447
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
448
|
+
const candidates: string[] = [];
|
|
449
|
+
const roots: RecipeRoot[] = [];
|
|
450
|
+
for (const ref of matched) {
|
|
451
|
+
const key = `${ref.recipe.namespace?.name ?? ""}/${ref.recipe.name}`;
|
|
452
|
+
const recipeDir = this.recipes[key];
|
|
453
|
+
if (recipeDir === undefined) continue;
|
|
363
454
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
455
|
+
if (declared !== undefined && !declaresRef(declared, ref.recipe.namespace?.name ?? "", key)) {
|
|
456
|
+
if (written.glob === true) continue;
|
|
457
|
+
return { kind: "not-a-dependency", recipe: key, includingRecipe };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (written.glob === true) {
|
|
461
|
+
// A brace alternative can hold a `.` or `..` segment that the ref
|
|
462
|
+
// service keeps inside one segment (`{..,x}`), so expand every
|
|
463
|
+
// alternative and refuse any that climbs.
|
|
464
|
+
const climbs = braceExpand(ref.path).some((alternative) =>
|
|
465
|
+
alternative.split("/").some((part) => part === "." || part === "..")
|
|
466
|
+
);
|
|
467
|
+
if (climbs) return { kind: "escapes-recipe", recipe: key, reference };
|
|
468
|
+
candidates.push(`${escapeGlob(recipeDir)}/${ref.path}`);
|
|
469
|
+
roots.push({ recipe: key, dir: recipeDir });
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const resolved = path.resolve(recipeDir, ref.path);
|
|
474
|
+
// A `~` reference addresses a recipe's own files. The ref service already
|
|
475
|
+
// refuses `.` and `..` segments; this catches whatever else would leave
|
|
476
|
+
// the recipe directory, so a reference can never have the compiler render
|
|
477
|
+
// anything on the machine into the project's output.
|
|
478
|
+
if (escapesRecipe(recipeDir, resolved)) {
|
|
479
|
+
return { kind: "escapes-recipe", recipe: key, reference };
|
|
480
|
+
}
|
|
481
|
+
candidates.push(resolved);
|
|
482
|
+
roots.push({ recipe: key, dir: recipeDir });
|
|
375
483
|
}
|
|
376
484
|
|
|
377
|
-
return
|
|
485
|
+
return written.glob === true
|
|
486
|
+
? { kind: "candidates", candidates, glob: true, roots }
|
|
487
|
+
: { kind: "candidates", candidates, roots };
|
|
378
488
|
}
|
|
379
489
|
}
|
|
380
490
|
|
|
381
491
|
/**
|
|
382
|
-
*
|
|
383
|
-
* its first segment names the recipe, and the rest is a file path inside it.
|
|
384
|
-
* This is an include path, not a ref; it is the one place such a path is split.
|
|
385
|
-
*
|
|
386
|
-
* splitIncludePath("workflow", "task-files/_partials/resume.md")
|
|
387
|
-
* // -> { recipe: "workflow/task-files", segments: ["_partials", "resume.md"] }
|
|
388
|
-
*
|
|
389
|
-
* @param namespace - The namespace the include line named.
|
|
390
|
-
* @param rest - Everything after `~<namespace>/`.
|
|
391
|
-
*/
|
|
392
|
-
function splitIncludePath(
|
|
393
|
-
namespace: string,
|
|
394
|
-
rest: string
|
|
395
|
-
): { recipe: string; segments: string[] } {
|
|
396
|
-
const [recipeName = "", ...segments] = rest
|
|
397
|
-
.split("/")
|
|
398
|
-
.filter((segment) => segment.length > 0);
|
|
399
|
-
return { recipe: `${namespace}/${recipeName}`, segments };
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/**
|
|
403
|
-
* Whether an inner path would address something outside the recipe directory.
|
|
492
|
+
* Whether a resolved path sits outside the recipe directory.
|
|
404
493
|
*
|
|
405
494
|
* @param recipeDir - The recipe's absolute directory.
|
|
406
|
-
* @param innerSegments - The inner path's segments, as they were written.
|
|
407
|
-
* @param inner - Those segments rejoined.
|
|
408
495
|
* @param resolved - What the inner path resolved to.
|
|
409
496
|
*/
|
|
410
|
-
function escapesRecipe(
|
|
411
|
-
recipeDir: string,
|
|
412
|
-
innerSegments: string[],
|
|
413
|
-
inner: string,
|
|
414
|
-
resolved: string
|
|
415
|
-
): boolean {
|
|
416
|
-
if (innerSegments.some((segment) => segment === "." || segment === "..")) return true;
|
|
417
|
-
if (inner !== "" && path.isAbsolute(inner)) return true;
|
|
497
|
+
function escapesRecipe(recipeDir: string, resolved: string): boolean {
|
|
418
498
|
if (resolved === recipeDir) return false;
|
|
419
|
-
|
|
420
499
|
const relative = path.relative(recipeDir, resolved);
|
|
421
500
|
return relative === "" || relative.startsWith("..") || path.isAbsolute(relative);
|
|
422
501
|
}
|
|
@@ -55,12 +55,19 @@ export const RECIPE_CONFIG_ALLOWED_KEYS = [
|
|
|
55
55
|
"_aliases",
|
|
56
56
|
"_vars",
|
|
57
57
|
"compilation",
|
|
58
|
-
"
|
|
58
|
+
"recipes",
|
|
59
59
|
"runtimeContext",
|
|
60
60
|
"store",
|
|
61
61
|
"varMappings",
|
|
62
62
|
] as const;
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The keys under `recipes` a recipe's config layer may set. `recipes.memories`
|
|
66
|
+
* decides which memories the project includes and in what order, which is the
|
|
67
|
+
* project's own choice, so a recipe may not set it.
|
|
68
|
+
*/
|
|
69
|
+
export const RECIPE_CONFIG_ALLOWED_RECIPES_KEYS = ["skills"] as const;
|
|
70
|
+
|
|
64
71
|
/**
|
|
65
72
|
* Plain-language reasons for the refused keys a recipe is most likely to try,
|
|
66
73
|
* so the warning says why rather than only that.
|
|
@@ -130,6 +137,27 @@ export function filterRecipeConfigLayer(
|
|
|
130
137
|
const config: Record<string, unknown> = {};
|
|
131
138
|
|
|
132
139
|
for (const key of Object.keys(raw)) {
|
|
140
|
+
if (key === "recipes") {
|
|
141
|
+
const value = (raw as Record<string, unknown>)[key];
|
|
142
|
+
const kept: Record<string, unknown> = {};
|
|
143
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
144
|
+
for (const sub of Object.keys(value)) {
|
|
145
|
+
if ((RECIPE_CONFIG_ALLOWED_RECIPES_KEYS as readonly string[]).includes(sub)) {
|
|
146
|
+
kept[sub] = (value as Record<string, unknown>)[sub];
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
warnings.push(
|
|
150
|
+
`The recipe ${recipeKey} tried to set 'recipes.${sub}' in a config layer, and sous ` +
|
|
151
|
+
`removed it before merging anything:\n ${layerPath}\n` +
|
|
152
|
+
`That key is not one a recipe may set (it configures the project, not the recipe). ` +
|
|
153
|
+
`Under 'recipes' a recipe's config layer may set only: ` +
|
|
154
|
+
`${RECIPE_CONFIG_ALLOWED_RECIPES_KEYS.join(", ")}.`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
config[key] = kept;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
133
161
|
if (allowed.has(key)) {
|
|
134
162
|
config[key] = (raw as Record<string, unknown>)[key];
|
|
135
163
|
continue;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matching recipe keys (`namespace/recipe`) against the lists a config holds, such
|
|
3
|
+
* as `recipes.memories.first` and `recipes.memories.exclude`.
|
|
4
|
+
*
|
|
5
|
+
* Each entry of such a list is a glob (`communication/*`), or, when it is
|
|
6
|
+
* written between two slashes (`/^tool-usage\//`), a regular expression. A
|
|
7
|
+
* regular expression is written as a string because the config kernel passes
|
|
8
|
+
* every layer through JSON, which has no way to carry a `RegExp`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { minimatch } from "minimatch";
|
|
12
|
+
import { ConfigError } from "../errors.js";
|
|
13
|
+
|
|
14
|
+
/** True when the string is written `/.../`, the spelling of a regular expression. */
|
|
15
|
+
export function isRegexSpelling(entry: string): boolean {
|
|
16
|
+
return entry.length >= 3 && entry.startsWith("/") && entry.endsWith("/");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Compiles one entry of a recipe key list into a matcher.
|
|
21
|
+
*
|
|
22
|
+
* @param entry - A glob, or a regular expression written `/.../`.
|
|
23
|
+
* @param where - The config key the entry came from, named in the error, such as
|
|
24
|
+
* `recipes.memories.exclude`.
|
|
25
|
+
* @throws ConfigError when the entry is a regular expression that does not compile.
|
|
26
|
+
*/
|
|
27
|
+
function compileEntry(entry: string, where: string): (key: string) => boolean {
|
|
28
|
+
if (isRegexSpelling(entry)) {
|
|
29
|
+
let expression: RegExp;
|
|
30
|
+
try {
|
|
31
|
+
expression = new RegExp(entry.slice(1, -1));
|
|
32
|
+
} catch (error) {
|
|
33
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
34
|
+
throw new ConfigError(
|
|
35
|
+
`The value ${JSON.stringify(entry)} in '${where}' is written as a regular ` +
|
|
36
|
+
`expression, but it is not a valid one: ${reason}`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return (key) => expression.test(key);
|
|
40
|
+
}
|
|
41
|
+
return (key) => minimatch(key, entry);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Compiles a list of globs and `/.../` regular expressions into one matcher over
|
|
46
|
+
* recipe keys. The matcher is true when ANY entry matches; an empty or missing
|
|
47
|
+
* list matches nothing.
|
|
48
|
+
*
|
|
49
|
+
* compileRecipeKeyMatcher(["communication/*", "/^tool-usage\\//"], "recipes.memories.first")("communication/tone");
|
|
50
|
+
* // -> true
|
|
51
|
+
*
|
|
52
|
+
* @param entries - The configured list.
|
|
53
|
+
* @param where - The config key the list came from, named in any error.
|
|
54
|
+
* @throws ConfigError when an entry is an invalid regular expression.
|
|
55
|
+
*/
|
|
56
|
+
export function compileRecipeKeyMatcher(
|
|
57
|
+
entries: readonly string[] | undefined,
|
|
58
|
+
where: string
|
|
59
|
+
): (recipeKey: string) => boolean {
|
|
60
|
+
const matchers = (entries ?? []).map((entry) => compileEntry(entry, where));
|
|
61
|
+
return (recipeKey) => matchers.some((matches) => matches(recipeKey));
|
|
62
|
+
}
|