@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,51 +1,65 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
3
+ import { globSync } from "glob";
4
+ import { minimatch } from "minimatch";
5
+ import { compareBytewise, hasGlob, type ViewFile } from "../services/ref-resolver/index.js";
2
6
  import { expandHome } from "./config-discovery.js";
3
- import type { NamespaceResolution, NamespaceResolver } from "./repos/namespace-resolver.js";
7
+ import type {
8
+ NamespaceResolution,
9
+ NamespaceResolver,
10
+ RecipeRoot,
11
+ } from "./repos/namespace-resolver.js";
4
12
 
5
13
  /**
6
- * @include path resolution: aliases, recipe namespaces, variable substitution,
7
- * and the ordered candidate search.
14
+ * @include path resolution: aliases, `#` names, recipe references, globs,
15
+ * variable substitution and the ordered candidate search.
8
16
  *
9
- * An include path (the part after `@`) is resolved to an ordered list of
10
- * candidate absolute paths. The caller tries each in order and uses the first
11
- * that exists on disk; if none exist, it errors listing every candidate tried.
17
+ * An include path (the part after `@`) resolves to an ordered list of groups.
18
+ * The caller uses the first group that names at least one file, and gets every
19
+ * file in it; if none does, it errors listing every path tried.
12
20
  *
13
21
  * Resolution pipeline for a raw path P (with leading `@` already stripped):
14
22
  * 1. Substitute ${vars} in P, then expand a leading `~/` to the home
15
- * directory (the `~` sigil on its own, with nothing but a separator after
16
- * it, names no alias or namespace, so `@~/notes/x.md` is unambiguous). If
17
- * the result is absolute, it is the sole candidate (feature:
18
- * `@${sousRootPath}/x.md`).
23
+ * directory (the `~` sigil with nothing but a separator after it names no
24
+ * namespace, so `@~/notes/x.md` is unambiguous). If the result is
25
+ * absolute, it is the sole candidate (`@${sousRootPath}/x.md`).
19
26
  * 2. Split the first segment (up to the first `/` or `:`) as the alias key,
20
27
  * the remainder as `rest`. If the key is a registered alias, push
21
- * join(base, rest) for EACH base in the alias's ordered array.
22
- * 3. If the key begins with `~` and a namespace resolver was supplied, ask it
23
- * for the recipe namespace named by the key (minus the `~`) and push its
24
- * candidates. Aliases are consulted first, so `~project` keeps meaning
25
- * the built-in alias even if a namespace of that name exists.
28
+ * join(base, rest) for EACH base in the alias's ordered array. A `#name`
29
+ * (`#project`) is registered in the alias map by the `#` name registry.
30
+ * 3. If P begins with `~` and holds a `/`, and a namespace resolver was
31
+ * supplied, hand the reference (P without the `~`) to it. The resolver
32
+ * reads it with the ref resolver service: `namespace/recipe/path`, any of
33
+ * the three possibly a glob, optionally with a `repo:` qualifier.
26
34
  * 4. Always push the relative candidate: join(baseDir, P), the FULL path
27
- * including the alias segment. This lets an alias augment a real relative
35
+ * including the first segment. This lets an alias augment a real relative
28
36
  * directory of the same name (e.g. `@stuff/x` tries the alias bases, then
29
37
  * `./stuff/x`).
30
- * 5. Follow every candidate with its `.tpl.` twin: `x.md` is followed by
31
- * `x.tpl.md`, and `x.tpl.md` by `x.md`. The literal spelling is always
32
- * tried first, and the twin comes right after it (not after every other
33
- * candidate), so an alias base still beats the relative fallback. A
34
- * writer therefore never has to know whether an included file has been
35
- * turned into a template or back.
38
+ * 5. Follow every NON-glob candidate with its `.tpl.` twin: `x.md` is
39
+ * followed by `x.tpl.md`, and `x.tpl.md` by `x.md`. The literal spelling
40
+ * is always tried first, and the twin comes right after it, so an alias
41
+ * base still beats the relative fallback.
36
42
  *
37
- * A key WITHOUT the `~` sigil never reaches the namespace resolver: a bare
38
- * `@path` is always a relative path or a declared alias, so include lines never
39
- * masquerade as filesystem paths.
43
+ * Any path may use glob syntax (`*`, `**`, `?`, `[..]`, `{a,b}`) after
44
+ * substitution. A glob names every file it matches, in bytewise path order, and
45
+ * a `~` glob also matches recipes (`@~*` + `/*` + `/memories/*.md`).
40
46
  *
41
- * Aliases whose names begin with `~` are reserved for built-ins; user aliases
42
- * may not use that prefix. The primary separator is `/` (TS-style,
43
- * `@alias/path`); `:` is accepted as an equivalent (`@alias:path`).
47
+ * A key WITHOUT a sigil never reaches the namespace resolver: a bare `@path` is
48
+ * always a relative path or a declared alias, so include lines never masquerade
49
+ * as filesystem paths.
50
+ *
51
+ * Aliases whose names begin with `~` or `#` are reserved: `~` for the home
52
+ * directory and recipe namespaces, `#` for names sous or a plugin registers.
53
+ * The primary separator is `/` (`@alias/path`); `:` is accepted as an
54
+ * equivalent for an alias (`@alias:path`).
44
55
  */
45
56
 
46
57
  /** An alias maps a name to an ordered list of absolute base directories. */
47
58
  export type AliasMap = Record<string, string[]>;
48
59
 
60
+ /** The files each view lists (`#memories` and so on), keyed by the name with its `#`. */
61
+ export type ViewMap = Record<string, ViewFile[]>;
62
+
49
63
  /**
50
64
  * Substitute ${varName} references in a string from a scope. Unknown
51
65
  * references are left untouched (matches settings.substituteVars behavior).
@@ -104,10 +118,16 @@ export function splitAliasKey(p: string): { key: string; rest: string } {
104
118
  return { key: m[1], rest: m[2] };
105
119
  }
106
120
 
107
- /** Options shared by {@link resolveInclude} and {@link resolveIncludeCandidates}. */
121
+ /** Options shared by {@link resolveInclude} and {@link resolveIncludeFiles}. */
108
122
  export type IncludeResolveOptions = {
109
- /** The resolved alias map (name → ordered base dirs). */
123
+ /** The resolved alias map (name → ordered base dirs); `#` names are in it too. */
110
124
  aliases?: AliasMap;
125
+ /**
126
+ * The files each view lists. An include of `#name/<path or glob>` for a name
127
+ * here selects among those virtual paths and names the real files behind the
128
+ * ones that match, in the listed order.
129
+ */
130
+ views?: ViewMap;
111
131
  /** Variable scope for ${var} substitution. */
112
132
  scope?: Record<string, string>;
113
133
  /** Directory of the including file (for the relative candidate). */
@@ -122,80 +142,297 @@ export type IncludeResolveOptions = {
122
142
  fromFile?: string;
123
143
  };
124
144
 
125
- /** A namespace lookup that produced no usable candidates, kept for error reporting. */
145
+ /** A `~` reference that produced no usable candidates, kept for error reporting. */
126
146
  export type NamespaceIssue = {
127
- /** The namespace that was asked for, without its `~` sigil. */
128
- namespace: string;
129
- /** The remainder of the reference (recipe name plus the path inside it). */
130
- rest: string;
147
+ /** The reference with its `~` sigil taken off. */
148
+ reference: string;
131
149
  /** The file (or directory) that performed the include. */
132
150
  fromFile: string;
133
151
  /** What the resolver returned. */
134
152
  resolution: NamespaceResolution;
135
153
  };
136
154
 
155
+ /**
156
+ * One place an include may be found. A group is found when any of its paths
157
+ * names at least one file, and then it supplies ALL of them: a plain include
158
+ * has one path per group, a glob over recipes has one per recipe.
159
+ */
160
+ export type IncludeGroup = {
161
+ /** Absolute paths, or glob patterns when `glob` is true. */
162
+ paths: string[];
163
+ /** True when the paths are patterns to expand. */
164
+ glob: boolean;
165
+ /**
166
+ * The recipe each path belongs to (same length and order as `paths`), when
167
+ * they came from a `~` reference. Every file a path names must have a real
168
+ * path inside that recipe's real directory; one that does not (a link
169
+ * leading out) is refused.
170
+ */
171
+ roots?: RecipeRoot[];
172
+ };
173
+
137
174
  /** The full result of resolving one include path. */
138
175
  export type IncludeResolution = {
139
- /** Ordered, de-duplicated absolute candidate paths. */
176
+ /** Every group, in the order they are tried; the first that names a file wins. */
177
+ groups: IncludeGroup[];
178
+ /** Every path or pattern, flattened, in order and de-duplicated: what an error lists as tried. */
140
179
  candidates: string[];
180
+ /** True when the include path holds glob syntax. */
181
+ glob: boolean;
141
182
  /**
142
- * Present when the first segment named a `~namespace` that the resolver could
143
- * not satisfy. The candidate list is still usable (it holds the alias and
144
- * relative fallbacks); this only explains what went wrong with the namespace.
183
+ * Present when a `~` reference could not be satisfied. The groups are still
184
+ * usable (they hold the alias and relative fallbacks); this only explains
185
+ * what went wrong with the reference.
145
186
  */
146
187
  namespaceIssue?: NamespaceIssue;
188
+ /** Present when the path starts with a `#` name nothing registered. */
189
+ hashIssue?: string;
190
+ /** True when the path names a view; a glob over a view may match nothing without being an error. */
191
+ view?: boolean;
147
192
  };
148
193
 
194
+ /** The files an include names, and how the search went. */
195
+ export type IncludeFiles = {
196
+ /** Every file the include names, each once, in the order they are included. */
197
+ files: string[];
198
+ /** Everything that was tried. */
199
+ candidates: string[];
200
+ /** True when the include path holds glob syntax. */
201
+ glob: boolean;
202
+ /** Why a `~` reference failed, when it did. */
203
+ namespaceIssue?: NamespaceIssue;
204
+ /** Why a `#` name failed, when it did. */
205
+ hashIssue?: string;
206
+ /** True when the path names a view. */
207
+ view?: boolean;
208
+ };
209
+
210
+ /** The `?name=value&...` query an include line may end with, after its `.md`. */
211
+ const QUERY_SUFFIX = /^(.*\.md)(\?[^?\s]*=\S*)$/;
212
+
213
+ /**
214
+ * Splits the query off an include path: `a/b.md?name=value` gives the path
215
+ * `a/b.md` and the query `?name=value`.
216
+ *
217
+ * @param raw - The include path, without its `@`.
218
+ */
219
+ export function splitIncludeQuery(raw: string): { path: string; query: string } {
220
+ const match = QUERY_SUFFIX.exec(raw);
221
+ return match === null ? { path: raw, query: "" } : { path: match[1]!, query: match[2]! };
222
+ }
223
+
224
+ /** The group of one literal path followed by its `.tpl.` twin. */
225
+ function literalGroups(candidate: string, root?: RecipeRoot): IncludeGroup[] {
226
+ return withTemplateTwins([candidate]).map((entry) => ({
227
+ paths: [entry],
228
+ glob: false,
229
+ ...(root === undefined ? {} : { roots: [root] }),
230
+ }));
231
+ }
232
+
233
+ /**
234
+ * Whether a file's real path sits inside a directory's real path, so a link
235
+ * cannot lead out of it. A path that cannot be resolved (it does not exist) is
236
+ * not an escape.
237
+ *
238
+ * @param file - An absolute file path.
239
+ * @param dir - An absolute directory path.
240
+ */
241
+ export function realPathInside(file: string, dir: string): boolean {
242
+ let realFile: string;
243
+ let realDir: string;
244
+ try {
245
+ realFile = fs.realpathSync(file);
246
+ realDir = fs.realpathSync(dir);
247
+ } catch {
248
+ return true;
249
+ }
250
+ return realFile === realDir || realFile.startsWith(realDir + path.sep);
251
+ }
252
+
149
253
  /**
150
- * Resolve an include path to its ordered candidates plus any namespace
151
- * diagnostic. Use this when the caller wants to report WHY a `~namespace`
152
- * reference failed; {@link resolveIncludeCandidates} is the plain-list form.
254
+ * Resolve an include path to its ordered groups plus any diagnostic. Nothing is
255
+ * read from disk. Use this when the caller wants to report WHY a `~` or `#`
256
+ * reference failed; {@link resolveIncludeFiles} reads the disk and returns the
257
+ * files.
153
258
  *
154
259
  * @param rawPath - The include path with the leading `@` already stripped.
155
260
  * @param opts - Aliases, variable scope, including directory and optional namespace resolver.
156
- * @returns The candidate list and, when a namespace lookup failed, the reason.
261
+ * @returns The groups, the flat candidate list and, when a reference failed, the reason.
157
262
  */
158
263
  export function resolveInclude(rawPath: string, opts: IncludeResolveOptions): IncludeResolution {
159
264
  const aliases = opts.aliases ?? {};
160
265
  const scope = opts.scope ?? {};
161
- const substituted = expandHome(substituteVars(rawPath, scope));
266
+ const withQuery = expandHome(substituteVars(rawPath, scope));
267
+ const substituted = splitIncludeQuery(withQuery).path;
268
+ const glob = hasGlob(substituted);
269
+
270
+ const finish = (
271
+ groups: IncludeGroup[],
272
+ extra: Pick<IncludeResolution, "namespaceIssue" | "hashIssue" | "view"> = {}
273
+ ): IncludeResolution => ({
274
+ groups,
275
+ candidates: [...new Set(groups.flatMap((group) => group.paths))],
276
+ glob,
277
+ ...extra,
278
+ });
279
+ const single = (candidate: string): IncludeGroup[] =>
280
+ glob ? [{ paths: [candidate], glob: true }] : literalGroups(candidate);
162
281
 
163
- // 1. Substituted (or home-expanded) to an absolute path → that, and its twin.
282
+ // 1. Substituted (or home-expanded) to an absolute path: that, and its twin.
164
283
  if (path.isAbsolute(substituted)) {
165
- return { candidates: withTemplateTwins([path.normalize(substituted)]) };
284
+ return finish(single(path.normalize(substituted)));
285
+ }
286
+
287
+ // A view lists virtual files; the include selects among them by glob or by
288
+ // exact virtual path and names the real files behind the matches.
289
+ const views = opts.views ?? {};
290
+ const viewKey = splitAliasKey(substituted);
291
+ if (viewKey.key.startsWith("#") && Object.prototype.hasOwnProperty.call(views, viewKey.key)) {
292
+ const includingRecipe = opts.namespaceResolver?.includingRecipe?.(opts.fromFile ?? opts.baseDir) ?? null;
293
+ if (includingRecipe !== null) {
294
+ return finish([], {
295
+ view: true,
296
+ hashIssue:
297
+ `The recipe "${includingRecipe}" may not include "${viewKey.key}", because a view lists ` +
298
+ `other recipes' files.\n` +
299
+ `A recipe includes another recipe's files with "@~namespace/recipe/..." and a ` +
300
+ `dependency declared in its manifest ("depends" or "subscribes").`,
301
+ });
302
+ }
303
+ const listed = views[viewKey.key]!;
304
+ const wanted = viewKey.rest;
305
+ const matched = listed.filter((entry) =>
306
+ glob ? minimatch(entry.path, wanted, { dot: true }) : entry.path === wanted
307
+ );
308
+ const issue =
309
+ !glob && matched.length === 0
310
+ ? `The view "${viewKey.key}" lists no file at "${wanted}".\n` +
311
+ (listed.length > 0
312
+ ? `It lists:\n${listed.map((entry) => ` ${viewKey.key}/${entry.path}`).join("\n")}`
313
+ : "It lists no files in this project.")
314
+ : undefined;
315
+ return finish(
316
+ matched.length > 0 ? [{ paths: [...new Set(matched.map((entry) => entry.file))], glob: false }] : [],
317
+ { view: true, ...(issue === undefined ? {} : { hashIssue: issue }) }
318
+ );
166
319
  }
167
320
 
168
- const candidates: string[] = [];
321
+ const groups: IncludeGroup[] = [];
169
322
  let namespaceIssue: NamespaceIssue | undefined;
323
+ let hashIssue: string | undefined;
170
324
 
171
- // 2. Alias bases (ordered), if the first segment is a registered alias.
325
+ // 2. Alias bases (ordered), if the first segment is a registered alias. A
326
+ // `#` name is an alias too: the registry puts every `#name` in the map.
172
327
  const { key, rest } = splitAliasKey(substituted);
173
328
  if (key && Object.prototype.hasOwnProperty.call(aliases, key)) {
174
- for (const base of aliases[key]) {
175
- candidates.push(path.resolve(base, rest));
176
- }
329
+ for (const base of aliases[key]!) groups.push(...single(path.resolve(base, rest)));
330
+ } else if (key.startsWith("#") && rest.length > 0) {
331
+ const known = [...Object.keys(aliases), ...Object.keys(views)].filter((name) =>
332
+ name.startsWith("#")
333
+ );
334
+ hashIssue =
335
+ `There is no built-in name "${key}" available here.\n` +
336
+ (known.length > 0
337
+ ? `Available names: ${known.sort().join(", ")}.`
338
+ : "No '#' names are available in this project.");
177
339
  }
178
340
 
179
- // 3. Recipe namespace, only for a `~`-sigil key naming something after it.
180
- // Aliases above already had their turn, so a built-in or user alias of the
181
- // same name is always preferred.
182
- if (opts.namespaceResolver && key.startsWith("~") && key.length > 1 && rest.length > 0) {
183
- const namespace = key.slice(1);
341
+ // 3. A recipe reference: a `~` sigil, then namespace, recipe and path. Only
342
+ // when a resolver was supplied and a `/` follows, so a file that is
343
+ // simply named `~notes.md` is still a relative path.
344
+ if (
345
+ opts.namespaceResolver &&
346
+ substituted.startsWith("~") &&
347
+ substituted.length > 1 &&
348
+ substituted.includes("/")
349
+ ) {
350
+ const reference = withQuery.slice(1);
184
351
  const fromFile = opts.fromFile ?? opts.baseDir;
185
- const resolution = opts.namespaceResolver.resolve({ namespace, rest, fromFile });
352
+ const resolution = opts.namespaceResolver.resolve({ reference, fromFile });
186
353
 
187
- if (resolution.kind === "candidates" && resolution.candidates.length > 0) {
188
- candidates.push(...resolution.candidates.map((c) => path.normalize(c)));
189
- } else {
190
- namespaceIssue = { namespace, rest, fromFile, resolution };
354
+ if (resolution.kind === "candidates" && resolution.glob === true) {
355
+ groups.push({
356
+ paths: resolution.candidates.map((c) => c),
357
+ glob: true,
358
+ ...(resolution.roots === undefined ? {} : { roots: resolution.roots }),
359
+ });
360
+ } else if (resolution.kind === "candidates" && resolution.candidates.length > 0) {
361
+ resolution.candidates.forEach((candidate, index) => {
362
+ groups.push(...literalGroups(path.normalize(candidate), resolution.roots?.[index]));
363
+ });
364
+ } else if (resolution.kind !== "candidates") {
365
+ namespaceIssue = { reference, fromFile, resolution };
191
366
  }
192
367
  }
193
368
 
194
369
  // 4. Relative fallback: the FULL substituted path under the including dir.
195
- candidates.push(path.resolve(opts.baseDir, substituted));
370
+ groups.push(...single(path.resolve(opts.baseDir, substituted)));
371
+
372
+ return finish(groups, {
373
+ ...(namespaceIssue === undefined ? {} : { namespaceIssue }),
374
+ ...(hashIssue === undefined ? {} : { hashIssue }),
375
+ });
376
+ }
196
377
 
197
- // 5. Each candidate's `.tpl.` twin right after it; de-duped, order kept.
198
- return { candidates: withTemplateTwins(candidates), namespaceIssue };
378
+ /**
379
+ * Expands a glob pattern to the files it matches, sorted by path bytewise so
380
+ * the order is the same on every machine.
381
+ *
382
+ * @param pattern - An absolute glob pattern.
383
+ */
384
+ function expandPattern(pattern: string): string[] {
385
+ return globSync(pattern, { absolute: true, nodir: true, dot: true }).sort(compareBytewise);
386
+ }
387
+
388
+ /**
389
+ * Resolve an include path to the files it names. The groups are tried in
390
+ * order and the first that names any file supplies all of them: a plain path
391
+ * is one file, a glob is every file it matches in sorted path order.
392
+ *
393
+ * @param rawPath - The include path with the leading `@` already stripped.
394
+ * @param opts - Aliases, variable scope, including directory and optional namespace resolver.
395
+ * @returns The files (empty when nothing was found) and what was tried.
396
+ */
397
+ export function resolveIncludeFiles(rawPath: string, opts: IncludeResolveOptions): IncludeFiles {
398
+ const resolution = resolveInclude(rawPath, opts);
399
+ const out: IncludeFiles = {
400
+ files: [],
401
+ candidates: resolution.candidates,
402
+ glob: resolution.glob,
403
+ ...(resolution.namespaceIssue === undefined ? {} : { namespaceIssue: resolution.namespaceIssue }),
404
+ ...(resolution.hashIssue === undefined ? {} : { hashIssue: resolution.hashIssue }),
405
+ ...(resolution.view === true ? { view: true } : {}),
406
+ };
407
+ for (const group of resolution.groups) {
408
+ const found = group.glob
409
+ ? group.paths.flatMap(expandPattern)
410
+ : group.paths.filter((candidate) => fs.existsSync(candidate));
411
+ if (group.roots !== undefined) {
412
+ const roots = group.roots;
413
+ const leaving = group.paths.flatMap((entry, index) => {
414
+ const root = roots[index];
415
+ if (root === undefined) return [];
416
+ const files = group.glob ? expandPattern(entry) : fs.existsSync(entry) ? [entry] : [];
417
+ return files.filter((file) => !realPathInside(file, root.dir)).map(() => root);
418
+ });
419
+ const { recipe } = leaving[0] ?? { recipe: "" };
420
+ if (leaving.length > 0) {
421
+ out.files = [];
422
+ out.namespaceIssue = {
423
+ reference: rawPath.replace(/^~/, ""),
424
+ fromFile: opts.fromFile ?? opts.baseDir,
425
+ resolution: { kind: "escapes-recipe", recipe, reference: rawPath.replace(/^~/, "") },
426
+ };
427
+ return out;
428
+ }
429
+ }
430
+ if (found.length > 0) {
431
+ out.files = [...new Set(found)];
432
+ return out;
433
+ }
434
+ }
435
+ return out;
199
436
  }
200
437
 
201
438
  /**
@@ -203,7 +440,7 @@ export function resolveInclude(rawPath: string, opts: IncludeResolveOptions): In
203
440
  *
204
441
  * @param rawPath - The include path with the leading `@` already stripped.
205
442
  * @param opts - Aliases, variable scope, including directory and optional namespace resolver.
206
- * @returns Ordered, de-duplicated absolute candidate paths.
443
+ * @returns Ordered, de-duplicated absolute candidate paths (patterns, for a glob).
207
444
  */
208
445
  export function resolveIncludeCandidates(rawPath: string, opts: IncludeResolveOptions): string[] {
209
446
  return resolveInclude(rawPath, opts).candidates;
@@ -238,10 +475,10 @@ export function resolveAliasPrefix(p: string, aliases: AliasMap): string[] {
238
475
  * Each user alias value may be a single string or an array of strings, and
239
476
  * each is run through ${var} substitution against `scope`.
240
477
  *
241
- * User aliases may NOT use names beginning with `~` (reserved for built-ins);
478
+ * User aliases may NOT use names beginning with `~` or `#` (reserved);
242
479
  * such entries are rejected via `onError` and ignored.
243
480
  *
244
- * @param opts.builtIns - Built-in alias map (already absolute; `~`-prefixed names).
481
+ * @param opts.builtIns - Built-in alias map (already absolute; `#`-prefixed names).
245
482
  * @param opts.userAliases - Ordered list of user `_aliases` blocks (root, then project).
246
483
  * @param opts.scope - Variable scope for substituting alias values.
247
484
  * @param opts.onError - Called with a message for each rejected/invalid entry.
@@ -266,9 +503,10 @@ export function buildAliasMap(opts: {
266
503
  for (const block of opts.userAliases ?? []) {
267
504
  if (!block) continue;
268
505
  for (const [name, value] of Object.entries(block)) {
269
- if (name.startsWith("~")) {
506
+ if (name.startsWith("~") || name.startsWith("#")) {
270
507
  opts.onError?.(
271
- `Alias "${name}" is invalid: names beginning with "~" are reserved for built-in aliases.`
508
+ `Alias "${name}" is invalid: names beginning with "~" or "#" are reserved ` +
509
+ `("~" for the home directory and recipe namespaces, "#" for built-in names).`
272
510
  );
273
511
  continue;
274
512
  }