@sous-io/sous 0.2.28 → 0.2.30

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 (52) hide show
  1. package/docs/markdown/_sidebar.md +2 -0
  2. package/docs/markdown/commands.md +10 -6
  3. package/docs/markdown/repositories-authoring.md +19 -11
  4. package/docs/markdown/repositories-consuming.md +34 -13
  5. package/docs/markdown/repositories-file-formats.md +88 -13
  6. package/docs/markdown/repositories-providers.md +8 -6
  7. package/docs/markdown/repositories-troubleshooting.md +6 -0
  8. package/docs/markdown/repositories-variables.md +13 -3
  9. package/docs/markdown/repositories.md +3 -3
  10. package/package.json +1 -1
  11. package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +11 -7
  12. package/recipes/core/sous-skills/skills/about-sous-repos/SKILL.tpl.md +7 -4
  13. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  14. package/src/commands/lock/rebuild.ts +7 -6
  15. package/src/commands/recipe/show.ts +163 -64
  16. package/src/commands/repo/contribute.ts +1 -10
  17. package/src/commands/repo/gc.ts +3 -2
  18. package/src/commands/repo/release.ts +20 -3
  19. package/src/commands/repo/search.ts +2 -1
  20. package/src/commands/vars/ask.ts +32 -10
  21. package/src/lib/refs/find.ts +202 -21
  22. package/src/lib/refs/index.ts +2 -0
  23. package/src/lib/refs/parse.ts +844 -0
  24. package/src/lib/refs/scopes.ts +34 -0
  25. package/src/lib/refs/settle.ts +219 -0
  26. package/src/lib/repos/catalog.ts +87 -80
  27. package/src/lib/repos/declarations.ts +177 -0
  28. package/src/lib/repos/formats/common.ts +14 -0
  29. package/src/lib/repos/formats/index-file.ts +42 -2
  30. package/src/lib/repos/formats/lockfile.ts +5 -3
  31. package/src/lib/repos/formats/recipe-manifest.ts +16 -212
  32. package/src/lib/repos/formats/variable-definition.ts +268 -0
  33. package/src/lib/repos/index.ts +0 -1
  34. package/src/lib/repos/lock-service.ts +3 -2
  35. package/src/lib/repos/locked-namespace-resolver.ts +21 -15
  36. package/src/lib/repos/locked-recipes.ts +2 -2
  37. package/src/lib/repos/namespace-resolver.ts +40 -47
  38. package/src/lib/repos/providers/base.ts +30 -0
  39. package/src/lib/repos/providers/github.ts +22 -0
  40. package/src/lib/repos/providers/gitlab.ts +68 -0
  41. package/src/lib/repos/providers/provider.ts +96 -0
  42. package/src/lib/repos/release/index-builder.ts +221 -47
  43. package/src/lib/repos/release/index.ts +1 -0
  44. package/src/lib/repos/release/plan.ts +8 -6
  45. package/src/lib/repos/release/settle.ts +237 -0
  46. package/src/lib/repos/release/tags.ts +3 -7
  47. package/src/lib/repos/resolver.ts +251 -53
  48. package/src/lib/repos/subscription-service.ts +355 -49
  49. package/src/lib/repos/update-plan.ts +3 -2
  50. package/src/lib/vars/mappings.ts +17 -30
  51. package/src/lib/vars/question-plan.ts +44 -21
  52. package/src/lib/repos/ref.ts +0 -513
@@ -0,0 +1,268 @@
1
+ /**
2
+ * A recipe's variable definitions: what each variable is called, what shape an
3
+ * answer takes, and what question to ask. A recipe manifest declares them
4
+ * (`recipe-manifest.ts` re-exports everything here), and `sous repo release`
5
+ * copies each version's definitions into the index, so the same fields and the
6
+ * same rules read both copies.
7
+ *
8
+ * This module imports nothing but the shared primitives, because the index
9
+ * reads it, and the index sits below the ref parser the manifest needs.
10
+ */
11
+
12
+ import { z } from "zod";
13
+ import {
14
+ envVarNameSchema,
15
+ extensibleObject,
16
+ forwardCompatibleObject,
17
+ variableNameSchema,
18
+ } from "./common.js";
19
+
20
+ // --- Variable definitions -----------------------------------------------------------------------
21
+
22
+ /** The value types a recipe may declare for a variable. */
23
+ export const VARIABLE_TYPES = [
24
+ "string",
25
+ "number",
26
+ "boolean",
27
+ "enum",
28
+ "path",
29
+ "url",
30
+ ] as const;
31
+
32
+ /** Which env file an answer is written to. */
33
+ export const VARIABLE_SCOPES = ["shared", "local"] as const;
34
+
35
+ /** Constraints checked against an answer before it is accepted or stored. */
36
+ const variableValidationShape = {
37
+ /** A regular expression the answer must match, as a string. */
38
+ pattern: z
39
+ .string()
40
+ .refine(
41
+ (value) => {
42
+ try {
43
+ new RegExp(value);
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ },
49
+ { message: "must be a valid regular expression" }
50
+ )
51
+ .optional(),
52
+ /** Minimum length of a string answer. */
53
+ minLength: z.number().int().min(0).optional(),
54
+ /** Maximum length of a string answer. */
55
+ maxLength: z.number().int().min(0).optional(),
56
+ /** Minimum value of a numeric answer. */
57
+ min: z.number().optional(),
58
+ /** Maximum value of a numeric answer. */
59
+ max: z.number().optional(),
60
+ /** The allowed answers. Required when the variable's type is 'enum'. */
61
+ enum: z.array(z.string()).min(1, "must list at least one option").optional(),
62
+ };
63
+
64
+ /** Constraints checked against an answer before it is accepted or stored. */
65
+ export const variableValidationSchema = extensibleObject(variableValidationShape);
66
+
67
+ /**
68
+ * The fields of a variable definition, shared by the two schemas that read
69
+ * one: the hand-written manifest, and the copy a release records in the index.
70
+ *
71
+ * @param validate - The schema for the `validate` block, strict or forward compatible.
72
+ */
73
+ function variableDefinitionShape<Validate extends z.ZodType>(validate: Validate) {
74
+ return {
75
+ /** The variable's camelCase name, as templates refer to it. */
76
+ name: variableNameSchema,
77
+ /**
78
+ * The environment variable an answer binds to. Authors may name an existing
79
+ * variable such as GITHUB_TOKEN to reuse a value already in the environment.
80
+ * When omitted, `sous repo release` derives an upper snake case default from
81
+ * the name; the runtime never derives one.
82
+ */
83
+ env: envVarNameSchema.optional(),
84
+ /** The answer's type, which decides how it is validated and prompted for. */
85
+ type: z.enum(VARIABLE_TYPES),
86
+ /** The one-line question shown when the variable is asked. */
87
+ prompt: z.string().min(1, "must not be empty"),
88
+ /**
89
+ * The paragraph that explains the variable: what it is for, what a good
90
+ * answer looks like, and what changes when it is set. Shown above the
91
+ * question when sous asks, and by `sous vars <name>`. Required, because a
92
+ * consumer reading the question has no other way to learn what a publisher
93
+ * meant by it.
94
+ */
95
+ description: z
96
+ .string({
97
+ error:
98
+ "is required: every published variable must explain itself in a sentence or " +
99
+ "two. The description is shown above the question when sous asks, and by " +
100
+ "'sous vars <name>'",
101
+ })
102
+ .min(1, "must not be empty"),
103
+ /**
104
+ * A realistic sample answer. It is documentation and prompt copy only; sous
105
+ * never stores it, never offers it as the answer, and never falls back to it.
106
+ * Use `default` for a value a project should actually start with.
107
+ */
108
+ example: z.union([z.string(), z.number(), z.boolean()], {
109
+ error:
110
+ "is required: every published variable must show what a real answer looks like. " +
111
+ "The example is shown with the question and by 'sous vars <name>'; it is " +
112
+ "documentation only and is never stored as the answer (use 'default' for that)",
113
+ }),
114
+ /** The value offered when the question is asked with nothing else in scope. */
115
+ default: z.union([z.string(), z.number(), z.boolean()]).optional(),
116
+ /** Whether an answer is needed for a build to run. Defaults to true. */
117
+ required: z.boolean().default(true),
118
+ /**
119
+ * Whether the answer is a secret. A secret is always written to the
120
+ * gitignored `.sous/.env.local`, never to the committed `.sous/.env`.
121
+ */
122
+ secret: z.boolean().default(false),
123
+ /**
124
+ * Which env file the answer is written to: 'shared' is `.sous/.env`, which is
125
+ * committed and shared with the team; 'local' is `.sous/.env.local`, which is
126
+ * gitignored and machine-specific. Defaults to 'shared'.
127
+ */
128
+ scope: z.enum(VARIABLE_SCOPES).default("shared"),
129
+ /** Constraints checked against an answer. */
130
+ validate: validate.optional(),
131
+ };
132
+ }
133
+
134
+ /** What `checkVariableDefinition` reads from a definition. */
135
+ type CheckedDefinition = {
136
+ type: VariableType;
137
+ secret: boolean;
138
+ scope: VariableScope;
139
+ default?: string | number | boolean | undefined;
140
+ example: string | number | boolean;
141
+ validate?:
142
+ | { minLength?: number; maxLength?: number; min?: number; max?: number; enum?: string[] }
143
+ | undefined;
144
+ };
145
+
146
+ /**
147
+ * The rules a variable definition obeys beyond the shape of each field, shared
148
+ * by both schemas so a definition the index records is held to exactly the rules
149
+ * its manifest was.
150
+ *
151
+ * @param definition - The definition, already parsed field by field.
152
+ * @param ctx - The refinement context issues are added to.
153
+ */
154
+ function checkVariableDefinition(definition: CheckedDefinition, ctx: z.RefinementCtx): void {
155
+ const { type, validate } = definition;
156
+
157
+ if (type === "enum" && (validate?.enum === undefined || validate.enum.length === 0)) {
158
+ ctx.addIssue({
159
+ code: "custom",
160
+ path: ["validate", "enum"],
161
+ message:
162
+ "a variable of type 'enum' must list its options under 'validate.enum'",
163
+ });
164
+ }
165
+
166
+ if (validate !== undefined) {
167
+ const { minLength, maxLength, min, max } = validate;
168
+ if (minLength !== undefined && maxLength !== undefined && minLength > maxLength) {
169
+ ctx.addIssue({
170
+ code: "custom",
171
+ path: ["validate", "maxLength"],
172
+ message: "must not be smaller than 'validate.minLength'",
173
+ });
174
+ }
175
+ if (min !== undefined && max !== undefined && min > max) {
176
+ ctx.addIssue({
177
+ code: "custom",
178
+ path: ["validate", "max"],
179
+ message: "must not be smaller than 'validate.min'",
180
+ });
181
+ }
182
+ }
183
+
184
+ // A secret written to the committed env file would leak, so the two fields
185
+ // are not allowed to contradict each other.
186
+ if (definition.secret && definition.scope === "shared") {
187
+ ctx.addIssue({
188
+ code: "custom",
189
+ path: ["scope"],
190
+ message:
191
+ "a secret variable is stored in the gitignored '.sous/.env.local', so its " +
192
+ "scope must be 'local' (or left unset)",
193
+ });
194
+ }
195
+
196
+ // `default` and `example` are both literal values written by the publisher,
197
+ // so both have to fit the variable they describe; an example that could
198
+ // never be a valid answer is worse than no example at all.
199
+ checkDeclaredValue(definition.default, "default");
200
+ checkDeclaredValue(definition.example, "example");
201
+
202
+ /**
203
+ * Checks one publisher-written literal against the declared type and the
204
+ * 'validate.enum' options.
205
+ *
206
+ * @param value - The literal to check, or undefined when it was omitted.
207
+ * @param field - The field name, used as the issue path.
208
+ */
209
+ function checkDeclaredValue(
210
+ value: string | number | boolean | undefined,
211
+ field: "default" | "example"
212
+ ): void {
213
+ if (value === undefined) return;
214
+
215
+ const expected =
216
+ type === "boolean" ? "boolean" : type === "number" ? "number" : "string";
217
+ if (typeof value !== expected) {
218
+ ctx.addIssue({
219
+ code: "custom",
220
+ path: [field],
221
+ message: `must be a ${expected}, to match the declared type '${type}'`,
222
+ });
223
+ } else if (
224
+ type === "enum" &&
225
+ validate?.enum !== undefined &&
226
+ !validate.enum.includes(String(value))
227
+ ) {
228
+ ctx.addIssue({
229
+ code: "custom",
230
+ path: [field],
231
+ message: "must be one of the options listed under 'validate.enum'",
232
+ });
233
+ }
234
+ }
235
+ }
236
+
237
+ /**
238
+ * A published variable definition: a specification, not a value. Definitions
239
+ * are inert; a question is asked only when a subscribed recipe needs the
240
+ * variable and no valid answer is already in scope.
241
+ */
242
+ export const variableDefinitionSchema = extensibleObject(
243
+ variableDefinitionShape(variableValidationSchema)
244
+ ).superRefine(checkVariableDefinition);
245
+
246
+ /**
247
+ * A variable definition as a release records it in the index, so a consumer
248
+ * can list a recipe's questions before its files are fetched. It is the
249
+ * manifest's definition, field for field and rule for rule; the one difference
250
+ * is that a field this sous does not define is kept rather than refused,
251
+ * because the index is read by every sous a project happens to pin, and a
252
+ * later one may add a field.
253
+ */
254
+ export const publishedVariableDefinitionSchema = forwardCompatibleObject(
255
+ variableDefinitionShape(forwardCompatibleObject(variableValidationShape))
256
+ ).superRefine(checkVariableDefinition);
257
+
258
+ /** One validated variable definition from a recipe manifest. */
259
+ export type VariableDefinition = z.infer<typeof variableDefinitionSchema>;
260
+
261
+ /** The constraints attached to a variable definition. */
262
+ export type VariableValidation = z.infer<typeof variableValidationSchema>;
263
+
264
+ /** The value type a variable definition declares. */
265
+ export type VariableType = (typeof VARIABLE_TYPES)[number];
266
+
267
+ /** Which env file an answer is written to. */
268
+ export type VariableScope = (typeof VARIABLE_SCOPES)[number];
@@ -18,7 +18,6 @@ export * from "./formats/lockfile.js";
18
18
  export * from "./formats/store-entry.js";
19
19
  export * from "./formats/links-map.js";
20
20
  export * from "./load-manifest.js";
21
- export * from "./ref.js";
22
21
  export * from "./identity.js";
23
22
 
24
23
  // Phase 2a: the machine-wide recipe store, under the user-level sous directory.
@@ -34,6 +34,7 @@ import type { ResolvedRecipe } from "./resolver.js";
34
34
  import type { RecipeStoreLike, StoreKey } from "./store/contract.js";
35
35
  import { builtInProviders, requireProvider } from "./providers/index.js";
36
36
  import { repoIdentity } from "./identity.js";
37
+ import { splitRecipeKey } from "../refs/parse.js";
37
38
  import type { ProviderOptions, RepoProvider } from "./providers/provider.js";
38
39
 
39
40
  /** What the lockfile needs to know about a repository. */
@@ -382,11 +383,11 @@ export class LockService {
382
383
  const report: RestoreReport = { restored: [], alreadyPresent: [] };
383
384
 
384
385
  for (const [key, entry] of Object.entries(lock.recipes)) {
385
- const namespace = key.slice(0, key.indexOf("/"));
386
+ const { namespace, name } = splitRecipeKey(key);
386
387
  const storeKey: StoreKey = {
387
388
  identity: lock.repos[entry.repo]!.identity,
388
389
  namespace,
389
- name: key.slice(namespace.length + 1),
390
+ name,
390
391
  version: entry.version,
391
392
  };
392
393
 
@@ -4,14 +4,17 @@
4
4
  * Phase 4 defined the `NamespaceResolver` contract and shipped an in-memory
5
5
  * implementation for tests. This is the implementation backed by the project's
6
6
  * actual state: the lockfile says which recipes are in play and at what version,
7
- * the links map and the store say where each one's files are, and each recipe's
8
- * own manifest says what it is allowed to address.
7
+ * the links map and the store say where each one's files are, and the
8
+ * lockfile's holder lists say what each recipe is allowed to address.
9
9
  *
10
10
  * The scoping rule is the whole point, and it is enforced by handing
11
11
  * `StaticNamespaceResolver` the right two maps:
12
12
  *
13
13
  * - a file inside a recipe may address only that recipe's own declared
14
- * dependencies (`depends` plus `subscribes`), at their pinned versions;
14
+ * dependencies (`depends` plus `subscribes`), at their pinned versions. The
15
+ * lockfile records which keys each recipe holds, however its manifest wrote
16
+ * them (a sibling ref, a locator, a URL copied from the browser), so that
17
+ * record is what decides it;
15
18
  * - a file in the project's own templates may address every recipe the
16
19
  * lockfile pins, whatever holds it: the project, a set it subscribes to,
17
20
  * or another recipe's `depends`.
@@ -28,16 +31,16 @@
28
31
  import semver from "semver";
29
32
  import type { Settings } from "../settings.js";
30
33
  import { resolveStoreRoot } from "../sous-home.js";
34
+ import { RefSource } from "../refs/scopes.js";
35
+ import { namespaceOfKey, parseShortRef, refKey } from "../refs/parse.js";
31
36
  import {
32
37
  StaticNamespaceResolver,
33
- normalizeRef,
34
38
  type DroppedRecipe,
35
39
  type NamespaceResolver,
36
40
  } from "./namespace-resolver.js";
37
41
  import {
38
42
  listLockedRecipes,
39
43
  readProjectLockfile,
40
- readRecipeManifestIn,
41
44
  type LockedRecipeLocation,
42
45
  } from "./locked-recipes.js";
43
46
  import { createIndexCache } from "./providers/index.js";
@@ -84,14 +87,11 @@ export function createProjectNamespaceResolver(
84
87
  for (const recipe of locked) {
85
88
  recipes[recipe.key] = recipe.dir;
86
89
 
87
- // A recipe declares what it may address in its own manifest. A recipe whose
88
- // files are not on disk yet declares nothing, which is the conservative
89
- // answer: it cannot be including anything either.
90
- const manifest = recipe.present ? readRecipeManifestIn(recipe.dir) : undefined;
91
- if (manifest === undefined) continue;
92
-
93
- const declared = [...(manifest.depends ?? []), ...(manifest.subscribes ?? [])];
94
- if (declared.length > 0) dependencies[recipe.key] = declared;
90
+ // Every recipe holding this one declared it, in `depends` or `subscribes`,
91
+ // and the lockfile records the key that declaration resolved to.
92
+ for (const holder of recipe.requestedBy) {
93
+ (dependencies[holder] ??= []).push(recipe.key);
94
+ }
95
95
  }
96
96
 
97
97
  // No `projectScope`: a project template may address every recipe the
@@ -126,10 +126,16 @@ export function explainUnpinnedRecipe(
126
126
  recipe: string,
127
127
  options: Pick<ProjectNamespaceResolverOptions, "sousDir" | "settings" | "env">
128
128
  ): DroppedRecipe | undefined {
129
- const namespace = recipe.slice(0, recipe.indexOf("/"));
129
+ const namespace = namespaceOfKey(recipe);
130
130
  for (const [key, entry] of Object.entries(options.settings.subscriptions ?? {})) {
131
131
  if (entry?.enabled !== false) continue;
132
- const ref = normalizeRef(key);
132
+ let ref: string;
133
+ try {
134
+ ref = refKey(parseShortRef(key, RefSource.Config));
135
+ } catch {
136
+ // The config schema already refuses a key that is not a ref.
137
+ continue;
138
+ }
133
139
  if (ref === recipe || ref === namespace) {
134
140
  return { by: "disabled-subscription", subscription: key };
135
141
  }
@@ -33,6 +33,7 @@ import {
33
33
  } from "./load-manifest.js";
34
34
  import { readEffectiveLinks } from "./links.js";
35
35
  import { identitySegments } from "./identity.js";
36
+ import { splitRecipeKey } from "../refs/parse.js";
36
37
  import { PROJECT_HOLDER } from "./formats/lockfile.js";
37
38
 
38
39
  /** One locked recipe, together with the directory its files are read from. */
@@ -164,8 +165,7 @@ export function listLockedRecipes(
164
165
 
165
166
  for (const key of keys.sort()) {
166
167
  const entry = lock.recipes[key]!;
167
- const namespace = key.slice(0, key.indexOf("/"));
168
- const name = key.slice(namespace.length + 1);
168
+ const { namespace, name } = splitRecipeKey(key);
169
169
 
170
170
  let dir: string | undefined;
171
171
  let linked = false;
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { namespaceOfKey } from "../refs/parse.js";
2
3
 
3
4
  /**
4
5
  * Namespace addressability for templates: the reserved `~` include sigil.
@@ -202,7 +203,7 @@ function describeDropped(
202
203
  dropped: DroppedRecipe,
203
204
  opts: { namespace: string; rest: string }
204
205
  ): string[] {
205
- const recipe = `${opts.namespace}/${opts.rest.split("/")[0] ?? ""}`;
206
+ const { recipe } = splitIncludePath(opts.namespace, opts.rest);
206
207
  if (dropped.by === "disabled-subscription") {
207
208
  return [
208
209
  `This project subscribes to "${dropped.subscription}", but that subscription is ` +
@@ -232,14 +233,15 @@ export type StaticNamespaceResolverOptions = {
232
233
  */
233
234
  recipes: Record<string, string>;
234
235
  /**
235
- * What each recipe declares, mapping `<namespace>/<recipe>` to the refs it
236
- * may address. An entry may be a full ref (`workflow/task-files`) or a bare
237
- * namespace (`workflow`, meaning every recipe in it). A recipe with no entry
238
- * declares nothing and may address only itself.
236
+ * What each recipe declares, mapping `<namespace>/<recipe>` to the keys it
237
+ * may address. An entry is a recipe key (`workflow/task-files`) or a bare
238
+ * namespace (`workflow`, meaning every recipe in it), already settled from
239
+ * however the manifest wrote it. A recipe with no entry declares nothing and
240
+ * may address only itself.
239
241
  */
240
242
  dependencies?: Record<string, string[]>;
241
243
  /**
242
- * What the project's own templates may address, in the same ref forms as
244
+ * What the project's own templates may address, as the same kind of keys as
243
245
  * `dependencies`. Omit it to make every known recipe addressable from
244
246
  * project templates, which is the rule for a real project: its templates may
245
247
  * address everything its lockfile pins.
@@ -293,7 +295,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
293
295
  private knownNamespaces(): string[] {
294
296
  const names = new Set<string>();
295
297
  for (const ref of Object.keys(this.recipes)) {
296
- names.add(ref.split("/")[0]);
298
+ names.add(namespaceOfKey(ref));
297
299
  }
298
300
  return [...names].sort();
299
301
  }
@@ -301,7 +303,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
301
303
  /** Every known recipe ref inside one namespace, sorted. */
302
304
  private recipesIn(namespace: string): string[] {
303
305
  return Object.keys(this.recipes)
304
- .filter((ref) => ref.split("/")[0] === namespace)
306
+ .filter((ref) => namespaceOfKey(ref) === namespace)
305
307
  .sort();
306
308
  }
307
309
 
@@ -329,9 +331,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
329
331
  resolve(request: NamespaceRequest): NamespaceResolution {
330
332
  const { namespace, rest, fromFile } = request;
331
333
 
332
- const segments = rest.split("/").filter((segment) => segment.length > 0);
333
- const recipeName = segments[0] ?? "";
334
- const ref = `${namespace}/${recipeName}`;
334
+ const { recipe: ref, segments } = splitIncludePath(namespace, rest);
335
335
 
336
336
  if (!this.knownNamespaces().includes(namespace)) {
337
337
  return {
@@ -361,7 +361,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
361
361
  return { kind: "not-a-dependency", recipe: ref, includingRecipe };
362
362
  }
363
363
 
364
- const inner = segments.slice(1).join("/");
364
+ const inner = segments.join("/");
365
365
  const resolved = path.resolve(recipeDir, inner);
366
366
 
367
367
  // A `~namespace` reference addresses a recipe's own files. Without this the
@@ -370,7 +370,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
370
370
  // segment check catches the written form, and the relative check catches
371
371
  // everything else, including an absolute inner path and any symlink-free
372
372
  // route out that normalisation would otherwise hide.
373
- if (escapesRecipe(recipeDir, segments.slice(1), inner, resolved)) {
373
+ if (escapesRecipe(recipeDir, segments, inner, resolved)) {
374
374
  return { kind: "escapes-recipe", recipe: ref, reference: `${namespace}/${rest}` };
375
375
  }
376
376
 
@@ -378,6 +378,27 @@ export class StaticNamespaceResolver implements NamespaceResolver {
378
378
  }
379
379
  }
380
380
 
381
+ /**
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
+
381
402
  /**
382
403
  * Whether an inner path would address something outside the recipe directory.
383
404
  *
@@ -401,46 +422,18 @@ function escapesRecipe(
401
422
  }
402
423
 
403
424
  /**
404
- * Strip the decorations a written reference may carry so it can be compared to
405
- * a plain `<namespace>/<recipe>` ref: a repository qualifier (a subscription's
406
- * `sous-public:misc/stuff`, or a manifest's
407
- * `github://owner/repo/misc/stuff` locator) and a trailing version range
408
- * (`misc/stuff@^1.2`).
409
- *
410
- * @param ref - A reference as written in a manifest or subscription entry.
411
- * @returns The bare `<namespace>` or `<namespace>/<recipe>` form.
412
- */
413
- export function normalizeRef(ref: string): string {
414
- const trimmed = ref.trim();
415
-
416
- // A locator URL names the repository first and the recipe last, so the two
417
- // trailing segments are the ref; everything before them is where it lives.
418
- const scheme = trimmed.indexOf("://");
419
- const body =
420
- scheme === -1
421
- ? trimmed.includes(":")
422
- ? trimmed.slice(trimmed.indexOf(":") + 1)
423
- : trimmed
424
- : trimmed.slice(scheme + 3).split("/").slice(-2).join("/");
425
-
426
- const at = body.lastIndexOf("@");
427
- return (at > 0 ? body.slice(0, at) : body).trim();
428
- }
429
-
430
- /**
431
- * Whether a list of declared refs covers a recipe, either by naming the recipe
432
- * itself or by naming its whole namespace.
425
+ * Whether a list of declared keys covers a recipe, either by naming the recipe
426
+ * itself or by naming its whole namespace. The keys are already settled
427
+ * (`namespace` or `namespace/recipe`): whatever form a manifest wrote a
428
+ * dependency in, the lockfile records the key it resolved to.
433
429
  *
434
- * @param declared - Declared refs (dependencies, or the project's subscriptions).
430
+ * @param declared - Declared keys (dependencies, or the project's subscriptions).
435
431
  * @param namespace - The namespace being addressed.
436
432
  * @param ref - The fully qualified recipe ref being addressed.
437
433
  * @returns True when the reference is in scope.
438
434
  */
439
435
  function declaresRef(declared: string[], namespace: string, ref: string): boolean {
440
- return declared.some((entry) => {
441
- const normalized = normalizeRef(entry);
442
- return normalized === ref || normalized === namespace;
443
- });
436
+ return declared.some((entry) => entry === ref || entry === namespace);
444
437
  }
445
438
 
446
439
  /**
@@ -37,6 +37,8 @@ import type {
37
37
  ProviderId,
38
38
  ProviderOptions,
39
39
  RepoProvider,
40
+ LocationReading,
41
+ WrittenLocation,
40
42
  } from "./provider.js";
41
43
 
42
44
  /**
@@ -68,6 +70,34 @@ export abstract class ProviderBase implements RepoProvider {
68
70
  options?: ProviderOptions
69
71
  ): Promise<void>;
70
72
 
73
+ // --- Reading a location ----------------------------------------------------
74
+
75
+ /** This provider's public host, declared by a provider that has one. */
76
+ declare readonly defaultHost?: string;
77
+
78
+ /**
79
+ * No readings: a provider that does not override this reads no location
80
+ * inside its repositories, so a ref naming one is refused with a sentence.
81
+ *
82
+ * @param _location - The written location; unused.
83
+ */
84
+ readLocation(_location: WrittenLocation): LocationReading[] {
85
+ return [];
86
+ }
87
+
88
+ /**
89
+ * `<id>://<host>/<repoPath>/<rest>`, with the host left out when it is this
90
+ * provider's default.
91
+ *
92
+ * @param host - The repository's host.
93
+ * @param repoPath - The repository's path on that host.
94
+ * @param rest - What is named inside it.
95
+ */
96
+ formatLocator(host: string, repoPath: string, rest: string): string {
97
+ const where = host === this.defaultHost ? repoPath : `${host}/${repoPath}`;
98
+ return `${this.id}://${where}${rest.length === 0 ? "" : `/${rest}`}`;
99
+ }
100
+
71
101
  // --- Subprocess plumbing ---------------------------------------------------
72
102
 
73
103
  /**
@@ -24,7 +24,11 @@ import { fetchText, type FetchLike } from "./http.js";
24
24
  import {
25
25
  buildCanonicalRepo,
26
26
  invalidRepoUrl,
27
+ readingsAfterRepository,
27
28
  splitRepoUrl,
29
+ withoutGitSuffix,
30
+ type LocationReading,
31
+ type WrittenLocation,
28
32
  type AuthStatus,
29
33
  type CanonicalRepo,
30
34
  type ChangeProposal,
@@ -83,6 +87,24 @@ export class GithubProvider extends ProviderBase {
83
87
  /** What GitHub calls a proposal. */
84
88
  readonly proposalNoun = "pull request";
85
89
 
90
+ /** The host a `github://` locator means when it names none. */
91
+ readonly defaultHost = GITHUB_HOST;
92
+
93
+ /**
94
+ * A GitHub repository is always an owner and a name, so the first two
95
+ * segments are the repository and everything after them names something
96
+ * inside it: a namespace, a recipe, or a browser path after `tree/` or
97
+ * `blob/`.
98
+ *
99
+ * @param location - The host and the segments after it.
100
+ */
101
+ readLocation(location: WrittenLocation): LocationReading[] {
102
+ const { segments } = location;
103
+ if (segments.length < 2) return [];
104
+ const repoPath = `${segments[0]}/${withoutGitSuffix(segments[1]!)}`;
105
+ return readingsAfterRepository(repoPath, segments.slice(2));
106
+ }
107
+
86
108
  matches(url: string): boolean {
87
109
  const parts = splitRepoUrl(url);
88
110
  return parts !== undefined && parts.host === GITHUB_HOST;