@sous-io/sous 0.2.31 → 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 (104) hide show
  1. package/README.md +7 -6
  2. package/docs/markdown/_sidebar.md +2 -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 +14 -9
  10. package/package.json +2 -1
  11. package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +32 -17
  12. package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +8 -2
  13. package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +4 -0
  14. package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +29 -9
  15. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  16. package/sous.config.schema.json +27 -15
  17. package/src/commands/lock/rebuild.ts +3 -6
  18. package/src/commands/namespace/show.ts +2 -2
  19. package/src/commands/recipe/show.ts +26 -9
  20. package/src/commands/repo/contribute.ts +32 -23
  21. package/src/commands/repo/gc.ts +1 -1
  22. package/src/commands/repo/remove.ts +1 -1
  23. package/src/commands/repo/search.ts +1 -1
  24. package/src/commands/vars/ask.ts +75 -54
  25. package/src/lib/build-service.ts +85 -11
  26. package/src/lib/config-schema.ts +59 -19
  27. package/src/lib/include-resolver.ts +307 -69
  28. package/src/lib/markdown-compiler.ts +273 -81
  29. package/src/lib/project-scaffold/templates.ts +19 -11
  30. package/src/lib/repos/catalog-inputs.ts +1 -1
  31. package/src/lib/repos/catalog.ts +98 -72
  32. package/src/lib/repos/declarations.ts +22 -12
  33. package/src/lib/repos/formats/index-file.ts +1 -1
  34. package/src/lib/repos/formats/recipe-manifest.ts +3 -4
  35. package/src/lib/repos/lock-service.ts +1 -1
  36. package/src/lib/repos/locked-namespace-resolver.ts +14 -6
  37. package/src/lib/repos/locked-recipe-lookup.ts +123 -0
  38. package/src/lib/repos/locked-recipes.ts +1 -1
  39. package/src/lib/repos/memories-hash-name.ts +33 -0
  40. package/src/lib/repos/namespace-resolver.ts +198 -119
  41. package/src/lib/repos/recipe-config-layers.ts +29 -1
  42. package/src/lib/repos/recipe-key-matcher.ts +62 -0
  43. package/src/lib/repos/recipe-memories.ts +175 -0
  44. package/src/lib/repos/recipe-targets.ts +27 -28
  45. package/src/lib/repos/ref-search.ts +1 -1
  46. package/src/lib/repos/release/index-builder.ts +23 -15
  47. package/src/lib/repos/release/plan.ts +14 -7
  48. package/src/lib/repos/release/settle.ts +38 -121
  49. package/src/lib/repos/release/submit-checkout.ts +42 -23
  50. package/src/lib/repos/release/tags.ts +1 -1
  51. package/src/lib/repos/resolver.ts +90 -72
  52. package/src/lib/repos/subscription-service.ts +192 -170
  53. package/src/lib/settings.ts +37 -33
  54. package/src/lib/vars/ask.ts +3 -3
  55. package/src/lib/vars/mappings.ts +17 -9
  56. package/src/services/ref-resolver/container.ts +107 -0
  57. package/src/services/ref-resolver/dependency.ts +60 -0
  58. package/src/services/ref-resolver/format.ts +197 -0
  59. package/src/services/ref-resolver/glob.ts +115 -0
  60. package/src/services/ref-resolver/hash-names.ts +155 -0
  61. package/src/services/ref-resolver/index.ts +55 -0
  62. package/src/services/ref-resolver/injectable.ts +36 -0
  63. package/src/services/ref-resolver/location.ts +62 -0
  64. package/src/services/ref-resolver/lookups/cached-index-lookup.ts +53 -0
  65. package/src/services/ref-resolver/lookups/catalog-lookup.ts +23 -0
  66. package/src/services/ref-resolver/lookups/catalog-matcher.ts +261 -0
  67. package/src/services/ref-resolver/lookups/chained-lookup.ts +27 -0
  68. package/src/services/ref-resolver/lookups/env-var-lookup.ts +65 -0
  69. package/src/services/ref-resolver/lookups/fetched-index-lookup.ts +150 -0
  70. package/src/services/ref-resolver/lookups/recorded-dependency-lookup.ts +62 -0
  71. package/src/services/ref-resolver/lookups/ref-lookup.ts +54 -0
  72. package/src/services/ref-resolver/lookups/variable-lookup.ts +123 -0
  73. package/src/services/ref-resolver/named-refs.ts +144 -0
  74. package/src/services/ref-resolver/parser/env-var-splitter.ts +27 -0
  75. package/src/services/ref-resolver/parser/location-splitter.ts +239 -0
  76. package/src/services/ref-resolver/parser/name-path-splitter.ts +200 -0
  77. package/src/services/ref-resolver/parser/partial-ref.ts +84 -0
  78. package/src/services/ref-resolver/parser/query-splitter.ts +61 -0
  79. package/src/services/ref-resolver/parser/range-splitter.ts +52 -0
  80. package/src/services/ref-resolver/parser/ref-parser.ts +115 -0
  81. package/src/services/ref-resolver/parser/repo-qualifier-splitter.ts +51 -0
  82. package/src/services/ref-resolver/parts.ts +115 -0
  83. package/src/services/ref-resolver/pruners/command-line-pruner.ts +47 -0
  84. package/src/services/ref-resolver/pruners/include-pruner.ts +66 -0
  85. package/src/services/ref-resolver/pruners/manifest-pruner.ts +130 -0
  86. package/src/services/ref-resolver/pruners/ref-pruner.ts +135 -0
  87. package/src/services/ref-resolver/pruners/rule-helpers.ts +44 -0
  88. package/src/services/ref-resolver/pruners/stored-key-pruner.ts +136 -0
  89. package/src/services/ref-resolver/ref-helpers.ts +62 -0
  90. package/src/services/ref-resolver/ref-picker-service.ts +204 -0
  91. package/src/{lib/repos/reference-report.ts → services/ref-resolver/ref-report.ts} +2 -40
  92. package/src/services/ref-resolver/ref-resolve-arguments.ts +47 -0
  93. package/src/services/ref-resolver/ref-resolve-result.ts +36 -0
  94. package/src/services/ref-resolver/ref-resolver-service.ts +323 -0
  95. package/src/services/ref-resolver/source.ts +48 -0
  96. package/src/services/ref-resolver/tokens.ts +25 -0
  97. package/src/services/ref-resolver/types.ts +155 -0
  98. package/src/templating/init-liquid-engine.ts +25 -18
  99. package/src/lib/refs/find.ts +0 -770
  100. package/src/lib/refs/index.ts +0 -14
  101. package/src/lib/refs/parse.ts +0 -844
  102. package/src/lib/refs/pick.ts +0 -147
  103. package/src/lib/refs/scopes.ts +0 -95
  104. package/src/lib/refs/settle.ts +0 -219
@@ -14,6 +14,7 @@ import {
14
14
  type DiscoveredConfig,
15
15
  } from "./config-discovery.js";
16
16
  import { ConfigError } from "./errors.js";
17
+ import { sharedHashNames } from "../services/ref-resolver/index.js";
17
18
  import { resolveSousHome } from "./sous-home.js";
18
19
  import { validateSettings } from "./config-schema.js";
19
20
  import { applyRepoDefaults } from "./repos/defaults.js";
@@ -148,23 +149,25 @@ type StoreConfig = {
148
149
  };
149
150
 
150
151
  /**
151
- * Where the files a subscribed recipe contributes are written, one list of
152
- * destination directories per content kind. Each destination is `${var}`
153
- * substituted like any other config path, and a kind may name several so the
154
- * same recipe feeds more than one agent directory.
152
+ * What a project does with each content kind its subscribed recipes publish.
155
153
  *
156
- * Only `skills` has a default (`<project root>/.claude/skills`, the project root
157
- * being the parent of `.sous/`). A kind with no destination is skipped, with one
158
- * warning naming this config key, because sous cannot guess where a project
159
- * wants its memories or its prompts.
154
+ * `skills.outputs` lists the destination directories for recipe skills, each
155
+ * `${var}` substituted; the default is `<project root>/.claude/skills`, the
156
+ * project root being the parent of `.sous/`. `memories.first` and
157
+ * `memories.exclude` are lists of globs, or `/.../` regular expressions, over
158
+ * `namespace/recipe` keys.
160
159
  */
161
- type RecipeOutputs = {
162
- /** Where recipe skill bundles are written. */
163
- skills?: string[];
164
- /** Where recipe memory files are written. */
165
- memories?: string[];
166
- /** Where recipe prompt files are written. */
167
- prompts?: string[];
160
+ type RecipesConfig = {
161
+ skills?: {
162
+ /** Where recipe skill bundles are written. */
163
+ outputs?: string[];
164
+ };
165
+ memories?: {
166
+ /** Recipes whose memories come first. */
167
+ first?: string[];
168
+ /** Recipes whose memories are left out. */
169
+ exclude?: string[];
170
+ };
168
171
  };
169
172
 
170
173
  /** Configuration for a launchable tool (e.g. claude, codex). */
@@ -204,9 +207,9 @@ export type Settings = {
204
207
  /** Knobs for the machine-wide recipe store. */
205
208
  store?: StoreConfig;
206
209
  /**
207
- * Where the files subscribed recipes contribute are written, per content kind.
210
+ * What the project does with each content kind its subscribed recipes publish.
208
211
  */
209
- recipeOutputs?: RecipeOutputs;
212
+ recipes?: RecipesConfig;
210
213
  /**
211
214
  * Variable mapping records, keyed by environment variable name, each bound to
212
215
  * one recipe variable written as `namespace/recipe/variableName` with an
@@ -820,30 +823,29 @@ function resolveAnswerLayer(
820
823
  }
821
824
 
822
825
  /**
823
- * Built-in `@include` aliases, always available and reserved (their names begin
824
- * with `~` so user `_aliases` can never shadow them). Add new entries here as
825
- * needed; keep names kebab-case.
826
+ * Built-in `@include` aliases: every `#` name the registry holds
827
+ * (`sharedHashNames`), each standing for its directories in this project. They
828
+ * are reserved (user `_aliases` may not start with `#`), so a user alias can
829
+ * never shadow one.
826
830
  *
827
- * - `~project` → the consuming project's root (`projectRoot`).
831
+ * - `#project` is the consuming project's root (`projectRoot`).
828
832
  *
829
- * There is exactly one, on purpose. Files that used to be reached through a
830
- * built-in alias pointing inside the sous package are published as recipes now,
831
- * and a recipe's files are addressed by its namespace (`@~workflow/task-files/
832
- * _partials/resume-task.md`), resolved against what the project has pinned. A
833
- * `~namespace` reference is NOT an alias: it is resolved separately, after the
834
- * alias map has been tried; see `locked-namespace-resolver.ts`.
833
+ * Files that used to be reached through a built-in alias pointing inside the
834
+ * sous package are published as recipes now, and a recipe's files are addressed
835
+ * by its namespace (`@~workflow/task-files/_partials/resume-task.md`),
836
+ * resolved against what the project has pinned. A `~` reference is NOT an
837
+ * alias: it is resolved separately, after the alias map has been tried; see
838
+ * `locked-namespace-resolver.ts`.
835
839
  */
836
840
  export function buildBuiltInAliases(scope: VarScope): AliasMap {
837
- const builtIns: AliasMap = {};
838
- if (scope.projectRoot) builtIns["~project"] = [scope.projectRoot];
839
- return builtIns;
841
+ return sharedHashNames().aliasMap(scope as Record<string, string>);
840
842
  }
841
843
 
842
844
  /**
843
845
  * Resolve the full `@include` alias map: built-ins, then the config's
844
846
  * `_aliases` block (user entries prepend, so they are tried first and fall
845
847
  * through to built-in bases of the same name). User alias names starting
846
- * with `~` are rejected (reserved).
848
+ * with `~` or `#` are a ConfigError (reserved).
847
849
  *
848
850
  * @param settings - The loaded settings (for the `_aliases` block).
849
851
  * @param scope - The resolved settings scope (for ${var} substitution + projectRoot).
@@ -853,7 +855,9 @@ export function resolveAliases(settings: Settings, scope: VarScope): AliasMap {
853
855
  builtIns: buildBuiltInAliases(scope),
854
856
  userAliases: [settings._aliases],
855
857
  scope,
856
- onError: warning,
858
+ onError: (message) => {
859
+ throw new ConfigError(message);
860
+ },
857
861
  });
858
862
  }
859
863
 
@@ -1002,7 +1006,7 @@ export function resolveCompilation(
1002
1006
 
1003
1007
  /* c8 ignore start */
1004
1008
  // Glob target: expand pattern into one CompilationTarget per matched file, skipping dirs.
1005
- // A leading alias (`~project/skills/**`) expands to one candidate pattern per alias
1009
+ // A leading alias (`#project/skills/**`) expands to one candidate pattern per alias
1006
1010
  // base; the first base that matches any files wins, mirroring the first-existing-wins
1007
1011
  // rule of @include resolution.
1008
1012
  const rawPattern = substituteVarsStrict(target.entryGlob!, targetScope, `${where}.entryGlob`);
@@ -42,7 +42,7 @@ import { choicePrompt } from "../../utils/choice-prompt.js";
42
42
  import { confirmPrompt } from "../../utils/confirm-prompt.js";
43
43
  import { valuePrompt } from "../../utils/value-prompt.js";
44
44
  import { ENV_VAR_NAME_PATTERN } from "../repos/formats/patterns.js";
45
- import { variableReferenceKey } from "../refs/find.js";
45
+ import { refKey, variableRefOf } from "../../services/ref-resolver/index.js";
46
46
  import type { VariableDefinition } from "../repos/formats/recipe-manifest.js";
47
47
  import {
48
48
  definedVariableKey,
@@ -152,7 +152,7 @@ export interface AskOptions {
152
152
  * Limit the run to these variables. Each entry is a variable's bare name, its
153
153
  * `namespace/recipe.name` key, or its fully qualified
154
154
  * `repository:namespace/recipe.name` reference. A command that resolved a
155
- * reference through `src/lib/refs/` passes the fully qualified form, which is
155
+ * reference through `src/services/ref-resolver/` passes the fully qualified form, which is
156
156
  * the only spelling that cannot mean two variables at once.
157
157
  */
158
158
  only?: string[];
@@ -182,7 +182,7 @@ export function answerFileFor(definition: VariableDefinition): AnswerFile {
182
182
  function isNamed(defined: DefinedVariable, only: string[] | undefined): boolean {
183
183
  if (only === undefined) return false;
184
184
  const key = definedVariableKey(defined);
185
- const qualified = variableReferenceKey(defined);
185
+ const qualified = refKey(variableRefOf(defined));
186
186
  return only.some(
187
187
  (name) => name === defined.definition.name || name === key || name === qualified
188
188
  );
@@ -27,8 +27,14 @@ import {
27
27
  updateManagedLayer,
28
28
  } from "../repos/managed-layer.js";
29
29
  import { VARIABLE_NAME_PATTERN } from "../repos/formats/patterns.js";
30
- import { RefSource } from "../refs/scopes.js";
31
- import { formatRef, parseShortRef, type ParsedRef } from "../refs/parse.js";
30
+ import {
31
+ RefSource,
32
+ formatRef,
33
+ namespaceOf,
34
+ parseNamedRef,
35
+ repoOf,
36
+ type NamedRef,
37
+ } from "../../services/ref-resolver/index.js";
32
38
  import type { DefinedVariable } from "./definition-source.js";
33
39
 
34
40
  /** File name of the machine-written mapping record layer. */
@@ -85,18 +91,20 @@ export function parseMappingTarget(input: string): MappingTarget {
85
91
  // ref part is read by the one ref parser and held to the stored form.
86
92
  const slash = trimmed.lastIndexOf("/");
87
93
  const variable = slash === -1 ? "" : trimmed.slice(slash + 1);
88
- let ref: ParsedRef = { namespace: "" };
94
+ let ref: NamedRef | undefined;
89
95
  try {
90
- ref = parseShortRef(trimmed.slice(0, Math.max(slash, 0)), RefSource.CommandLine);
96
+ ref = parseNamedRef(trimmed.slice(0, Math.max(slash, 0)), RefSource.CommandLine);
91
97
  } catch (error) {
92
98
  fail((error as Error).message.split("\n")[0]!.replace(/^Invalid ref '[^']*': /, ""));
93
99
  }
94
- const { repo, namespace, recipe } = ref;
95
- if (recipe === undefined || ref.range !== undefined) {
100
+ if (ref?.kind !== "recipe" || ref.range !== undefined) {
96
101
  fail("a target names a namespace, a recipe and a variable, joined by slashes.");
97
102
  }
98
- if (formatRef(ref) !== formatRef(ref).toLowerCase()) {
99
- fail(`the repository, namespace and recipe in '${formatRef(ref)}' must be lowercase.`);
103
+ const repo = repoOf(ref!)?.name;
104
+ const namespace = namespaceOf(ref!)!.name;
105
+ const recipe = ref!.kind === "recipe" ? ref!.name : "";
106
+ if (formatRef(ref!) !== formatRef(ref!).toLowerCase()) {
107
+ fail(`the repository, namespace and recipe in '${formatRef(ref!)}' must be lowercase.`);
100
108
  }
101
109
  if (!VARIABLE_NAME_PATTERN.test(variable)) {
102
110
  fail(
@@ -105,7 +113,7 @@ export function parseMappingTarget(input: string): MappingTarget {
105
113
  );
106
114
  }
107
115
 
108
- const parsed: MappingTarget = { namespace, recipe: recipe!, variable };
116
+ const parsed: MappingTarget = { namespace, recipe, variable };
109
117
  if (repo !== undefined) parsed.repo = repo;
110
118
  return parsed;
111
119
  }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The Inversify container of the ref services.
3
+ *
4
+ * Every part is bound once and shared: the parser, every splitter, every
5
+ * pruner, the resolver and the picker. Splitters, pruners and providers are
6
+ * bound many times under one token each (`REF_TOKENS`), and the parser and the
7
+ * resolver receive them as lists, so a plugin adds a splitter, a pruner or a
8
+ * provider by binding one more under the same token on a container it builds
9
+ * with `createRefContainer()`:
10
+ *
11
+ * const container = createRefContainer();
12
+ * container.bind(REF_TOKENS.Splitter).to(MyNewFormSplitter);
13
+ * const resolver = container.get<RefResolverService>(REF_TOKENS.Resolver);
14
+ *
15
+ * The `#` names are bound the same way: one more `HashName` under
16
+ * `REF_TOKENS.HashName` registers a view or a built-in, and a name bound twice
17
+ * is an error when the registry is first read.
18
+ *
19
+ * Callers that have no container of their own use `sharedRefResolver`,
20
+ * `sharedHashNames` and `sharedRefPicker`, which come from one container built the first time they
21
+ * are read.
22
+ */
23
+
24
+ import { Container } from "inversify";
25
+ import { builtInProviders } from "../../lib/repos/providers/index.js";
26
+ import { EnvVarSplitter } from "./parser/env-var-splitter.js";
27
+ import { LocationSplitter } from "./parser/location-splitter.js";
28
+ import { NamePathSplitter } from "./parser/name-path-splitter.js";
29
+ import { QuerySplitter } from "./parser/query-splitter.js";
30
+ import { RangeSplitter } from "./parser/range-splitter.js";
31
+ import { RefParser } from "./parser/ref-parser.js";
32
+ import { RepoQualifierSplitter } from "./parser/repo-qualifier-splitter.js";
33
+ import { CommandLinePruner } from "./pruners/command-line-pruner.js";
34
+ import { IncludePruner } from "./pruners/include-pruner.js";
35
+ import { ManifestPruner } from "./pruners/manifest-pruner.js";
36
+ import { ConfigPruner, LockfilePruner } from "./pruners/stored-key-pruner.js";
37
+ import { MemoriesHashName } from "../../lib/repos/memories-hash-name.js";
38
+ import { HashNameRegistry, ProjectHashName } from "./hash-names.js";
39
+ import { RefPickerService } from "./ref-picker-service.js";
40
+ import { RefResolverService } from "./ref-resolver-service.js";
41
+ import { REF_TOKENS } from "./tokens.js";
42
+
43
+ /**
44
+ * Builds a container holding every ref service. Each call returns a fresh
45
+ * container, so a plugin or a test may bind more splitters or pruners without
46
+ * touching anyone else's.
47
+ */
48
+ export function createRefContainer(): Container {
49
+ const container = new Container({ defaultScope: "Singleton" });
50
+
51
+ for (const provider of builtInProviders()) {
52
+ container.bind(REF_TOKENS.Provider).toConstantValue(provider);
53
+ }
54
+
55
+ for (const splitter of [
56
+ QuerySplitter,
57
+ RangeSplitter,
58
+ LocationSplitter,
59
+ RepoQualifierSplitter,
60
+ NamePathSplitter,
61
+ EnvVarSplitter,
62
+ ]) {
63
+ container.bind(REF_TOKENS.Splitter).to(splitter);
64
+ }
65
+
66
+ for (const pruner of [
67
+ CommandLinePruner,
68
+ ConfigPruner,
69
+ ManifestPruner,
70
+ LockfilePruner,
71
+ IncludePruner,
72
+ ]) {
73
+ container.bind(REF_TOKENS.Pruner).to(pruner);
74
+ }
75
+
76
+ container.bind(REF_TOKENS.HashName).to(ProjectHashName);
77
+ container.bind(REF_TOKENS.HashName).to(MemoriesHashName);
78
+ container.bind(REF_TOKENS.HashNames).to(HashNameRegistry);
79
+ container.bind(REF_TOKENS.Parser).to(RefParser);
80
+ container.bind(REF_TOKENS.Resolver).to(RefResolverService);
81
+ container.bind(REF_TOKENS.Picker).to(RefPickerService);
82
+ return container;
83
+ }
84
+
85
+ /** The container the shared instances come from, built on first use. */
86
+ let sharedContainer: Container | undefined;
87
+
88
+ /** The one container callers with no container of their own share. */
89
+ export function getSharedRefContainer(): Container {
90
+ sharedContainer ??= createRefContainer();
91
+ return sharedContainer;
92
+ }
93
+
94
+ /** The shared resolver, for callers that have no container. */
95
+ export function sharedRefResolver(): RefResolverService {
96
+ return getSharedRefContainer().get<RefResolverService>(REF_TOKENS.Resolver);
97
+ }
98
+
99
+ /** The shared registry of `#` names, for callers that have no container. */
100
+ export function sharedHashNames(): HashNameRegistry {
101
+ return getSharedRefContainer().get<HashNameRegistry>(REF_TOKENS.HashNames);
102
+ }
103
+
104
+ /** The shared picker, for callers that have no container. */
105
+ export function sharedRefPicker(): RefPickerService {
106
+ return getSharedRefContainer().get<RefPickerService>(REF_TOKENS.Picker);
107
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Settling a manifest dependency that reads more than one way, from what is
3
+ * already at hand.
4
+ *
5
+ * A manifest entry usually says exactly what it means. A GitLab URL with nested
6
+ * groups does not say where the project path ends, and a browser URL names a
7
+ * folder, so the parser returns every reading. A release settles such an entry
8
+ * and records the repository it settled on beside each key it resolved
9
+ * (`RecordedDependencies`); a consumer reads that record and never probes.
10
+ */
11
+
12
+ import { sharedRefResolver } from "./container.js";
13
+ import {
14
+ isConfirmedByRecord,
15
+ type RecordedDependencies,
16
+ } from "./lookups/recorded-dependency-lookup.js";
17
+ import { RefSource } from "./source.js";
18
+ import type { SousRef } from "./types.js";
19
+
20
+ /** What `settleDependency` may consult, beyond the dependency itself. */
21
+ export type SettleDependencyOptions = {
22
+ /** What the declaring version's index entry records about its dependencies. */
23
+ recorded?: RecordedDependencies;
24
+ /**
25
+ * Whether a reading is already known to be right from what is on this
26
+ * machine: its repository is trusted and its cached index publishes what it
27
+ * names. Used only when the index recorded nothing, and it never fetches.
28
+ */
29
+ known?: (reading: SousRef) => boolean;
30
+ };
31
+
32
+ /**
33
+ * The one reading a manifest dependency settles on, or undefined when it reads
34
+ * more than one way and nothing at hand says which: the declaring recipe's
35
+ * index recorded no answer, and what this machine already knows does not pick
36
+ * exactly one.
37
+ *
38
+ * settleDependency("workflow/alpha");
39
+ * // -> the recipe workflow/alpha
40
+ *
41
+ * @param written - The dependency as the manifest wrote it.
42
+ * @param options - The index's record, and what is known locally.
43
+ * @throws A ConfigError when a manifest may not write the entry that way.
44
+ */
45
+ export function settleDependency(
46
+ written: string,
47
+ options: SettleDependencyOptions = {}
48
+ ): SousRef | undefined {
49
+ const { refs } = sharedRefResolver().parse(written, RefSource.Manifest);
50
+ if (refs.length === 1) return refs[0];
51
+
52
+ const confirmed = refs.filter((ref) => isConfirmedByRecord(ref, options.recorded));
53
+ if (confirmed.length === 1) return confirmed[0];
54
+
55
+ if (options.known !== undefined) {
56
+ const known = refs.filter(options.known);
57
+ if (known.length === 1) return known[0];
58
+ }
59
+ return undefined;
60
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Printing a ref: its key (what everything is stored and compared under) and
3
+ * its canonical written form (how sous prints it).
4
+ */
5
+
6
+ import {
7
+ builtInProviders,
8
+ providerById,
9
+ type RepoProvider,
10
+ } from "../../lib/repos/providers/index.js";
11
+ import { repoOf } from "./parts.js";
12
+ import { KIND_LABELS, type RepoRef, type SousRef } from "./types.js";
13
+
14
+ /**
15
+ * The part of a key that names the repository: its short name, or its
16
+ * identity when it was named by location.
17
+ */
18
+ function repoPart(repo: RepoRef | undefined): string | undefined {
19
+ if (repo === undefined) return undefined;
20
+ return repo.name ?? repo.location?.identity;
21
+ }
22
+
23
+ /**
24
+ * The path part of a key, without any repository.
25
+ *
26
+ * @param ref - Any ref.
27
+ */
28
+ function pathPart(ref: SousRef): string {
29
+ switch (ref.kind) {
30
+ case "repo":
31
+ return ref.browsed === undefined ? "" : `tree/${ref.browsed}`;
32
+ case "namespace":
33
+ return ref.name;
34
+ case "recipe":
35
+ return ref.namespace === undefined ? ref.name : `${ref.namespace.name}/${ref.name}`;
36
+ case "recipeFile":
37
+ return `${pathPart(ref.recipe)}/${ref.path}`;
38
+ case "variable": {
39
+ const recipe = ref.recipe === undefined ? "" : `${pathPart(ref.recipe)}.`;
40
+ return `${recipe}${ref.name}`;
41
+ }
42
+ case "envVar":
43
+ return ref.name;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * The canonical key of a ref, with its version range and query values left
49
+ * off, so the same thing is never keyed twice under two spellings. A
50
+ * repository qualifier is part of the key, because the same recipe name in two
51
+ * repositories is two things.
52
+ *
53
+ * refKey(namespace("workflow")); // -> "workflow"
54
+ * refKey(recipe("task-files", "workflow", "sous-recipes")); // -> "sous-recipes:workflow/task-files"
55
+ * refKey(variable("apiUrl", recipe)); // -> "workflow/task-files.apiUrl"
56
+ *
57
+ * @param ref - Any ref.
58
+ */
59
+ export function refKey(ref: SousRef): string {
60
+ const repo = ref.kind === "envVar" ? undefined : repoPart(repoOf(ref));
61
+ const rest = pathPart(ref);
62
+ if (ref.kind === "repo") return repo === undefined ? rest : rest === "" ? repo : `${repo}:${rest}`;
63
+ return repo === undefined ? rest : `${repo}:${rest}`;
64
+ }
65
+
66
+ /**
67
+ * The key of a ref together with its kind, which is what tells a namespace
68
+ * `workflow` from a repository `workflow` apart.
69
+ *
70
+ * @param ref - Any ref.
71
+ */
72
+ export function refIdentity(ref: SousRef): string {
73
+ return `${ref.kind} ${refKey(ref)}`;
74
+ }
75
+
76
+ /**
77
+ * Writes the repository qualifier in front of what is named inside it: `name:`
78
+ * for a repository this project knows by short name (whatever else it knows of
79
+ * it), and the provider's canonical locator for one known only by location.
80
+ */
81
+ function qualified(
82
+ repo: RepoRef | undefined,
83
+ rest: string,
84
+ providers: RepoProvider[]
85
+ ): string {
86
+ if (repo === undefined) return rest;
87
+ if (repo.name !== undefined) return rest === "" ? repo.name : `${repo.name}:${rest}`;
88
+ if (repo.location !== undefined) {
89
+ const { host, repoPath } = repo.location;
90
+ const provider = providerById(repo.location.provider, providers);
91
+ if (provider !== undefined && provider.id !== "local") {
92
+ return provider.formatLocator(host, repoPath, rest);
93
+ }
94
+ return rest === "" ? repo.location.url : `${repo.location.url}/${rest}`;
95
+ }
96
+ return rest;
97
+ }
98
+
99
+ /**
100
+ * The canonical written form of a ref, which is how sous prints it: the short
101
+ * form for a short ref and each provider's own locator for a located one, then
102
+ * the version range, then the query values.
103
+ *
104
+ * formatRef(parse("https://github.com/o/r/w/a@^1")[0]); // -> "github://o/r/w/a@^1"
105
+ *
106
+ * @param ref - Any ref.
107
+ * @param providers - The providers that format locators. Defaults to the built-ins.
108
+ */
109
+ export function formatRef(ref: SousRef, providers: RepoProvider[] = builtInProviders()): string {
110
+ const body = qualified(ref.kind === "envVar" ? undefined : repoOf(ref), pathPart(ref), providers);
111
+ const written =
112
+ ref.kind === "recipe" || ref.kind === "recipeFile" || ref.kind === "repo"
113
+ ? ref.range
114
+ : undefined;
115
+ const range = written === undefined ? "" : `@${written}`;
116
+ const pairs = Object.entries(ref.vars ?? {}).map(
117
+ ([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`
118
+ );
119
+ return `${body}${range}${pairs.length === 0 ? "" : `?${pairs.join("&")}`}`;
120
+ }
121
+
122
+ /**
123
+ * What kind of thing a ref names, in a sentence.
124
+ *
125
+ * @param ref - Any ref.
126
+ */
127
+ export function refKindLabel(ref: SousRef): string {
128
+ return KIND_LABELS[ref.kind];
129
+ }
130
+
131
+ /**
132
+ * How a ref is written to a person, so they can type it back: its key when a
133
+ * repository's short name qualifies it, and its canonical locator when only a
134
+ * location does (a ref that has not been matched to a repository this project
135
+ * knows by name).
136
+ *
137
+ * refSpelling(recipe "task-files" in "sous-recipes") // -> "sous-recipes:workflow/task-files"
138
+ * refSpelling(a recipe at a located repository) // -> its canonical locator
139
+ *
140
+ * @param ref - Any ref.
141
+ */
142
+ export function refSpelling(ref: SousRef): string {
143
+ const repo = ref.kind === "envVar" ? undefined : repoOf(ref);
144
+ if (repo !== undefined && repo.name === undefined && repo.location !== undefined) {
145
+ return formatRef(ref);
146
+ }
147
+ return refKey(ref);
148
+ }
149
+
150
+ /** The repository a person is told a ref lives in: its short name, or where it lives. */
151
+ function repoLabel(repo: RepoRef | undefined): string {
152
+ return repo?.name ?? repo?.location?.url ?? "unknown";
153
+ }
154
+
155
+ /**
156
+ * Describes one ref in the words a person choosing between several needs: the
157
+ * fully qualified name, and what it actually means.
158
+ *
159
+ * @param ref - A known ref, as a lookup returns it.
160
+ */
161
+ export function describeRef(ref: SousRef): string {
162
+ const summary = ref.description === undefined ? "" : `: ${ref.description}`;
163
+ const key = refSpelling(ref);
164
+
165
+ switch (ref.kind) {
166
+ case "repo": {
167
+ const where = ref.location?.url;
168
+ return `${key} (the repository '${ref.name ?? key}'${where === undefined ? "" : `, at ${where}`})`;
169
+ }
170
+ case "namespace":
171
+ return (
172
+ `${key} (the whole namespace '${ref.name}' in the ` +
173
+ `repository '${repoLabel(ref.repo)}')`
174
+ );
175
+ case "recipe":
176
+ return (
177
+ `${key} (the recipe '${ref.name}' in the namespace ` +
178
+ `'${ref.namespace?.name ?? "unknown"}' of the repository ` +
179
+ `'${repoLabel(repoOf(ref))}'${summary})`
180
+ );
181
+ case "recipeFile":
182
+ return `${key} (the file '${ref.path}' of the recipe '${pathPart(ref.recipe)}'${summary})`;
183
+ case "variable":
184
+ return (
185
+ `${key} (the variable '${ref.name}' of the recipe ` +
186
+ `'${ref.recipe === undefined ? "unknown" : pathPart(ref.recipe)}'${summary})`
187
+ );
188
+ case "envVar": {
189
+ const first = ref.variables?.[0];
190
+ const answers =
191
+ first === undefined
192
+ ? ""
193
+ : `answering '${first.name}' of the recipe '${first.recipe === undefined ? "unknown" : pathPart(first.recipe)}'`;
194
+ return `${ref.name} (the environment variable${answers === "" ? "" : ` ${answers}`}${summary})`;
195
+ }
196
+ }
197
+ }