@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.
Files changed (103) hide show
  1. package/README.md +7 -6
  2. package/docs/markdown/_sidebar.md +1 -0
  3. package/docs/markdown/commands.md +8 -4
  4. package/docs/markdown/configuration.md +28 -4
  5. package/docs/markdown/repositories-authoring.md +22 -2
  6. package/docs/markdown/repositories-consuming.md +40 -11
  7. package/docs/markdown/repositories-file-formats.md +28 -13
  8. package/docs/markdown/repositories-quickstart.md +1 -1
  9. package/docs/markdown/repositories.md +8 -4
  10. package/package.json +2 -1
  11. package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +25 -17
  12. package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +4 -0
  13. package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +21 -9
  14. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  15. package/sous.config.schema.json +27 -15
  16. package/src/commands/lock/rebuild.ts +3 -6
  17. package/src/commands/namespace/show.ts +2 -2
  18. package/src/commands/recipe/show.ts +26 -9
  19. package/src/commands/repo/contribute.ts +32 -23
  20. package/src/commands/repo/gc.ts +1 -1
  21. package/src/commands/repo/remove.ts +1 -1
  22. package/src/commands/repo/search.ts +1 -1
  23. package/src/commands/vars/ask.ts +75 -54
  24. package/src/lib/build-service.ts +85 -11
  25. package/src/lib/config-schema.ts +59 -19
  26. package/src/lib/include-resolver.ts +307 -69
  27. package/src/lib/markdown-compiler.ts +273 -81
  28. package/src/lib/project-scaffold/templates.ts +19 -11
  29. package/src/lib/repos/catalog-inputs.ts +1 -1
  30. package/src/lib/repos/catalog.ts +98 -72
  31. package/src/lib/repos/declarations.ts +22 -12
  32. package/src/lib/repos/formats/index-file.ts +1 -1
  33. package/src/lib/repos/formats/recipe-manifest.ts +3 -4
  34. package/src/lib/repos/lock-service.ts +1 -1
  35. package/src/lib/repos/locked-namespace-resolver.ts +14 -6
  36. package/src/lib/repos/locked-recipe-lookup.ts +123 -0
  37. package/src/lib/repos/locked-recipes.ts +1 -1
  38. package/src/lib/repos/memories-hash-name.ts +33 -0
  39. package/src/lib/repos/namespace-resolver.ts +198 -119
  40. package/src/lib/repos/recipe-config-layers.ts +29 -1
  41. package/src/lib/repos/recipe-key-matcher.ts +62 -0
  42. package/src/lib/repos/recipe-memories.ts +175 -0
  43. package/src/lib/repos/recipe-targets.ts +27 -28
  44. package/src/lib/repos/ref-search.ts +1 -1
  45. package/src/lib/repos/release/index-builder.ts +23 -15
  46. package/src/lib/repos/release/plan.ts +14 -7
  47. package/src/lib/repos/release/settle.ts +38 -121
  48. package/src/lib/repos/release/submit-checkout.ts +42 -23
  49. package/src/lib/repos/release/tags.ts +1 -1
  50. package/src/lib/repos/resolver.ts +90 -72
  51. package/src/lib/repos/subscription-service.ts +192 -170
  52. package/src/lib/settings.ts +37 -33
  53. package/src/lib/vars/ask.ts +3 -3
  54. package/src/lib/vars/mappings.ts +17 -9
  55. package/src/services/ref-resolver/container.ts +107 -0
  56. package/src/services/ref-resolver/dependency.ts +60 -0
  57. package/src/services/ref-resolver/format.ts +197 -0
  58. package/src/services/ref-resolver/glob.ts +115 -0
  59. package/src/services/ref-resolver/hash-names.ts +155 -0
  60. package/src/services/ref-resolver/index.ts +55 -0
  61. package/src/services/ref-resolver/injectable.ts +36 -0
  62. package/src/services/ref-resolver/location.ts +62 -0
  63. package/src/services/ref-resolver/lookups/cached-index-lookup.ts +53 -0
  64. package/src/services/ref-resolver/lookups/catalog-lookup.ts +23 -0
  65. package/src/services/ref-resolver/lookups/catalog-matcher.ts +261 -0
  66. package/src/services/ref-resolver/lookups/chained-lookup.ts +27 -0
  67. package/src/services/ref-resolver/lookups/env-var-lookup.ts +65 -0
  68. package/src/services/ref-resolver/lookups/fetched-index-lookup.ts +150 -0
  69. package/src/services/ref-resolver/lookups/recorded-dependency-lookup.ts +62 -0
  70. package/src/services/ref-resolver/lookups/ref-lookup.ts +54 -0
  71. package/src/services/ref-resolver/lookups/variable-lookup.ts +123 -0
  72. package/src/services/ref-resolver/named-refs.ts +144 -0
  73. package/src/services/ref-resolver/parser/env-var-splitter.ts +27 -0
  74. package/src/services/ref-resolver/parser/location-splitter.ts +239 -0
  75. package/src/services/ref-resolver/parser/name-path-splitter.ts +200 -0
  76. package/src/services/ref-resolver/parser/partial-ref.ts +84 -0
  77. package/src/services/ref-resolver/parser/query-splitter.ts +61 -0
  78. package/src/services/ref-resolver/parser/range-splitter.ts +52 -0
  79. package/src/services/ref-resolver/parser/ref-parser.ts +115 -0
  80. package/src/services/ref-resolver/parser/repo-qualifier-splitter.ts +51 -0
  81. package/src/services/ref-resolver/parts.ts +115 -0
  82. package/src/services/ref-resolver/pruners/command-line-pruner.ts +47 -0
  83. package/src/services/ref-resolver/pruners/include-pruner.ts +66 -0
  84. package/src/services/ref-resolver/pruners/manifest-pruner.ts +130 -0
  85. package/src/services/ref-resolver/pruners/ref-pruner.ts +135 -0
  86. package/src/services/ref-resolver/pruners/rule-helpers.ts +44 -0
  87. package/src/services/ref-resolver/pruners/stored-key-pruner.ts +136 -0
  88. package/src/services/ref-resolver/ref-helpers.ts +62 -0
  89. package/src/services/ref-resolver/ref-picker-service.ts +204 -0
  90. package/src/{lib/repos/reference-report.ts → services/ref-resolver/ref-report.ts} +2 -40
  91. package/src/services/ref-resolver/ref-resolve-arguments.ts +47 -0
  92. package/src/services/ref-resolver/ref-resolve-result.ts +36 -0
  93. package/src/services/ref-resolver/ref-resolver-service.ts +323 -0
  94. package/src/services/ref-resolver/source.ts +48 -0
  95. package/src/services/ref-resolver/tokens.ts +25 -0
  96. package/src/services/ref-resolver/types.ts +155 -0
  97. package/src/templating/init-liquid-engine.ts +25 -18
  98. package/src/lib/refs/find.ts +0 -770
  99. package/src/lib/refs/index.ts +0 -14
  100. package/src/lib/refs/parse.ts +0 -844
  101. package/src/lib/refs/pick.ts +0 -147
  102. package/src/lib/refs/scopes.ts +0 -95
  103. package/src/lib/refs/settle.ts +0 -219
@@ -1,13 +1,26 @@
1
1
  import path from "node:path";
2
- import { namespaceOfKey } from "../refs/parse.js";
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>/<rest>` (and the equivalent
8
- * `{% render "~<namespace>/<rest>" %}`) addresses a recipe namespace rather
9
- * than the filesystem. `<rest>` begins with the recipe name and continues with
10
- * the path inside that recipe, so
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 single `~namespace/rest` resolution request. */
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
- * Everything after the namespace segment: the recipe name, then the path
61
- * inside that recipe (e.g. `task-files/_partials/resume.md`). Never has a
62
- * leading separator.
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
- rest: string;
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
- /** Ordered absolute paths to try, most preferred first. An empty list means the lookup produced nothing. */
83
- | { kind: "candidates"; candidates: string[] }
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 `~namespace/rest` references to candidate absolute paths. */
141
+ /** Resolves `~` references to candidate absolute paths. */
113
142
  export interface NamespaceResolver {
114
143
  /**
115
- * Resolve one `~namespace/rest` reference.
144
+ * Resolve one `~` reference.
116
145
  *
117
- * @param request - The namespace, the remainder of the reference, and the including file.
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 "${opts.namespace}" available here.`);
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, opts));
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 "${opts.namespace}" holds no recipe named "${resolution.recipe}".`);
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 "${opts.namespace}" currently holds no recipes.`
206
+ : `The namespace "${namespace}" currently holds no recipes.`
163
207
  );
164
- if (resolution.dropped) lines.push(...describeDropped(resolution.dropped, opts));
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 "~${opts.namespace}".`
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 and may not be an absolute path.`
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 opts - The namespace and the rest of the reference, for the recipe's name.
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
- private includingRecipe(fromFile: string): string | null {
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
- resolve(request: NamespaceRequest): NamespaceResolution {
332
- const { namespace, rest, fromFile } = request;
333
-
334
- const { recipe: ref, segments } = splitIncludePath(namespace, rest);
335
-
336
- if (!this.knownNamespaces().includes(namespace)) {
337
- return {
338
- kind: "unknown-namespace",
339
- known: this.knownNamespaces(),
340
- ...this.droppedFor(ref, fromFile),
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
- const recipeDir = this.recipes[ref];
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
- if (!recipeDir) {
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: ref,
350
- known: this.recipesIn(namespace),
351
- ...this.droppedFor(ref, fromFile),
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
- if (declared !== undefined && !declaresRef(declared, namespace, ref)) {
361
- return { kind: "not-a-dependency", recipe: ref, includingRecipe };
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
- const inner = segments.join("/");
365
- const resolved = path.resolve(recipeDir, inner);
366
-
367
- // A `~namespace` reference addresses a recipe's own files. Without this the
368
- // reference could walk out of the recipe with `..` segments and have the
369
- // compiler render anything on the machine into the project's output. The
370
- // segment check catches the written form, and the relative check catches
371
- // everything else, including an absolute inner path and any symlink-free
372
- // route out that normalisation would otherwise hide.
373
- if (escapesRecipe(recipeDir, segments, inner, resolved)) {
374
- return { kind: "escapes-recipe", recipe: ref, reference: `${namespace}/${rest}` };
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 { kind: "candidates", candidates: [resolved] };
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
- * Takes the part of a `@~namespace/...` include line after the namespace apart:
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
- "recipeOutputs",
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
+ }