@sous-io/sous 0.2.32 → 0.2.34

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 (111) 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-variables.md +16 -0
  10. package/docs/markdown/repositories.md +8 -4
  11. package/package.json +2 -1
  12. package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +33 -20
  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 +21 -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 +93 -15
  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 +289 -82
  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 +32 -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/answers.ts +9 -1
  55. package/src/lib/vars/ask.ts +3 -3
  56. package/src/lib/vars/display.ts +1 -3
  57. package/src/lib/vars/index.ts +1 -0
  58. package/src/lib/vars/mappings.ts +17 -9
  59. package/src/lib/vars/secrets.ts +131 -0
  60. package/src/services/ref-resolver/container.ts +107 -0
  61. package/src/services/ref-resolver/dependency.ts +60 -0
  62. package/src/services/ref-resolver/format.ts +197 -0
  63. package/src/services/ref-resolver/glob.ts +115 -0
  64. package/src/services/ref-resolver/hash-names.ts +155 -0
  65. package/src/services/ref-resolver/index.ts +55 -0
  66. package/src/services/ref-resolver/injectable.ts +36 -0
  67. package/src/services/ref-resolver/location.ts +62 -0
  68. package/src/services/ref-resolver/lookups/cached-index-lookup.ts +53 -0
  69. package/src/services/ref-resolver/lookups/catalog-lookup.ts +23 -0
  70. package/src/services/ref-resolver/lookups/catalog-matcher.ts +261 -0
  71. package/src/services/ref-resolver/lookups/chained-lookup.ts +27 -0
  72. package/src/services/ref-resolver/lookups/env-var-lookup.ts +65 -0
  73. package/src/services/ref-resolver/lookups/fetched-index-lookup.ts +150 -0
  74. package/src/services/ref-resolver/lookups/recorded-dependency-lookup.ts +62 -0
  75. package/src/services/ref-resolver/lookups/ref-lookup.ts +54 -0
  76. package/src/services/ref-resolver/lookups/variable-lookup.ts +123 -0
  77. package/src/services/ref-resolver/named-refs.ts +144 -0
  78. package/src/services/ref-resolver/parser/env-var-splitter.ts +27 -0
  79. package/src/services/ref-resolver/parser/location-splitter.ts +239 -0
  80. package/src/services/ref-resolver/parser/name-path-splitter.ts +200 -0
  81. package/src/services/ref-resolver/parser/partial-ref.ts +84 -0
  82. package/src/services/ref-resolver/parser/query-splitter.ts +61 -0
  83. package/src/services/ref-resolver/parser/range-splitter.ts +52 -0
  84. package/src/services/ref-resolver/parser/ref-parser.ts +115 -0
  85. package/src/services/ref-resolver/parser/repo-qualifier-splitter.ts +51 -0
  86. package/src/services/ref-resolver/parts.ts +115 -0
  87. package/src/services/ref-resolver/pruners/command-line-pruner.ts +47 -0
  88. package/src/services/ref-resolver/pruners/include-pruner.ts +66 -0
  89. package/src/services/ref-resolver/pruners/manifest-pruner.ts +130 -0
  90. package/src/services/ref-resolver/pruners/ref-pruner.ts +135 -0
  91. package/src/services/ref-resolver/pruners/rule-helpers.ts +44 -0
  92. package/src/services/ref-resolver/pruners/stored-key-pruner.ts +136 -0
  93. package/src/services/ref-resolver/ref-helpers.ts +62 -0
  94. package/src/services/ref-resolver/ref-picker-service.ts +204 -0
  95. package/src/{lib/repos/reference-report.ts → services/ref-resolver/ref-report.ts} +2 -40
  96. package/src/services/ref-resolver/ref-resolve-arguments.ts +47 -0
  97. package/src/services/ref-resolver/ref-resolve-result.ts +36 -0
  98. package/src/services/ref-resolver/ref-resolver-service.ts +323 -0
  99. package/src/services/ref-resolver/source.ts +48 -0
  100. package/src/services/ref-resolver/tokens.ts +25 -0
  101. package/src/services/ref-resolver/types.ts +155 -0
  102. package/src/templating/init-liquid-engine.ts +33 -18
  103. package/src/templating/lib/secret-scope.ts +41 -0
  104. package/src/templating/tags/exportScalarVarsJs.ts +5 -2
  105. package/src/templating/tags/showVars.ts +8 -2
  106. package/src/lib/refs/find.ts +0 -770
  107. package/src/lib/refs/index.ts +0 -14
  108. package/src/lib/refs/parse.ts +0 -844
  109. package/src/lib/refs/pick.ts +0 -147
  110. package/src/lib/refs/scopes.ts +0 -95
  111. package/src/lib/refs/settle.ts +0 -219
@@ -0,0 +1,23 @@
1
+ import type { SousRef } from "../types.js";
2
+ import { CatalogMatcher, type CatalogRepo } from "./catalog-matcher.js";
3
+ import type { RefLookup, RefMatch } from "./ref-lookup.js";
4
+
5
+ /**
6
+ * Answers repositories, namespaces and recipes from a catalog a caller has
7
+ * already built (which repositories it knows, and what each publishes). It
8
+ * reads nothing, fetches nothing and never fails.
9
+ */
10
+ export class CatalogLookup implements RefLookup {
11
+ private readonly matcher: CatalogMatcher;
12
+
13
+ /**
14
+ * @param repos - The repositories to search, in the order their matches are listed.
15
+ */
16
+ constructor(repos: CatalogRepo[]) {
17
+ this.matcher = new CatalogMatcher(repos);
18
+ }
19
+
20
+ async find(candidate: SousRef): Promise<RefMatch[]> {
21
+ return this.matcher.match(candidate);
22
+ }
23
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Matching repositories, namespaces and recipes against a catalog of what is
3
+ * published. Every lookup that knows repositories (cached indexes, fetched
4
+ * indexes, the definitions in play) builds a catalog and asks this.
5
+ *
6
+ * Names are matched the same way everywhere: the exact spelling first, then
7
+ * ignoring case, and a name may be a glob pattern. A match is returned in the
8
+ * spelling the catalog publishes.
9
+ */
10
+
11
+ import type { IndexFile } from "../../../lib/repos/formats/index-file.js";
12
+ import { matchName, type NameMatch } from "../glob.js";
13
+ import { refKey } from "../format.js";
14
+ import type { NamespaceRef, RecipeRef, RefLocation, RepoRef, SousRef } from "../types.js";
15
+ import type { RefMatch } from "./ref-lookup.js";
16
+
17
+ /** One repository, and what it publishes. */
18
+ export type CatalogRepo = {
19
+ /** The short name this project calls it, when it has one. */
20
+ name?: string;
21
+ /** Where it lives, which is how a ref written as a location finds it. */
22
+ location?: RefLocation;
23
+ /** Every namespace it publishes. */
24
+ namespaces: Array<{ name: string; description?: string }>;
25
+ /** Every recipe it publishes. */
26
+ recipes: Array<{ namespace: string; name: string; description?: string; path?: string }>;
27
+ };
28
+
29
+ /**
30
+ * The catalog entry for one repository's index.
31
+ *
32
+ * @param index - The repository's index.
33
+ * @param repo - What the project knows of the repository: its short name and where it lives.
34
+ */
35
+ export function catalogRepoOfIndex(
36
+ index: IndexFile,
37
+ repo: { name?: string; location?: RefLocation } = {}
38
+ ): CatalogRepo {
39
+ return {
40
+ ...(repo.name === undefined ? {} : { name: repo.name }),
41
+ ...(repo.location === undefined ? {} : { location: repo.location }),
42
+ namespaces: Object.entries(index.namespaces).map(([namespace, declared]) => ({
43
+ name: namespace,
44
+ ...(declared?.description === undefined ? {} : { description: declared.description }),
45
+ })),
46
+ recipes: Object.entries(index.recipes).map(([key, recipe]) => {
47
+ const slash = key.indexOf("/");
48
+ return {
49
+ namespace: key.slice(0, slash),
50
+ name: key.slice(slash + 1),
51
+ path: recipe.path,
52
+ ...(recipe.description === undefined ? {} : { description: recipe.description }),
53
+ };
54
+ }),
55
+ };
56
+ }
57
+
58
+ /** The weaker of two matches: one folded part makes the whole thing folded. */
59
+ function both(left: NameMatch, right: NameMatch): NameMatch {
60
+ if (left === undefined || right === undefined) return undefined;
61
+ return left === "folded" || right === "folded" ? "folded" : "exact";
62
+ }
63
+
64
+ /** Matches refs against a catalog, repository by repository, in catalog order. */
65
+ export class CatalogMatcher {
66
+ /**
67
+ * @param repos - The repositories to search, in the order their matches are listed.
68
+ */
69
+ constructor(private readonly repos: CatalogRepo[]) {}
70
+
71
+ /**
72
+ * Every repository, namespace or recipe in the catalog a candidate names.
73
+ * Candidates of any other kind match nothing.
74
+ *
75
+ * @param candidate - One reading of a written ref.
76
+ */
77
+ match(candidate: SousRef): RefMatch[] {
78
+ const found: Array<RefMatch & { repoIndex: number }> = [];
79
+
80
+ this.repos.forEach((repo, repoIndex) => {
81
+ const add = (ref: SousRef, spelling: NameMatch): void => {
82
+ if (spelling === undefined) return;
83
+ found.push({ ref, exactSpelling: spelling === "exact", repoIndex });
84
+ };
85
+
86
+ switch (candidate.kind) {
87
+ case "repo": {
88
+ if (candidate.browsed !== undefined) {
89
+ for (const settled of this.settleBrowsed(candidate, repo)) add(settled, "exact");
90
+ } else {
91
+ add(...this.matchRepo(candidate, repo));
92
+ }
93
+ break;
94
+ }
95
+ case "namespace": {
96
+ const qualifier = this.qualifies(candidate.repo, repo);
97
+ for (const namespace of repo.namespaces) {
98
+ const spelling = both(qualifier, matchName(candidate.name, namespace.name));
99
+ if (spelling === undefined) continue;
100
+ add(
101
+ this.carry(candidate, {
102
+ kind: "namespace",
103
+ name: namespace.name,
104
+ repo: this.knownRepo(repo),
105
+ ...(namespace.description === undefined ? {} : { description: namespace.description }),
106
+ }),
107
+ spelling
108
+ );
109
+ }
110
+ break;
111
+ }
112
+ case "recipe": {
113
+ const qualifier = this.qualifies(candidate.namespace?.repo ?? candidate.repo, repo);
114
+ for (const recipe of repo.recipes) {
115
+ const inNamespace =
116
+ candidate.namespace === undefined
117
+ ? "exact"
118
+ : matchName(candidate.namespace.name, recipe.namespace);
119
+ const spelling = both(
120
+ both(qualifier, inNamespace),
121
+ matchName(candidate.name, recipe.name)
122
+ );
123
+ if (spelling === undefined) continue;
124
+ add(this.carry(candidate, this.knownRecipe(repo, recipe)), spelling);
125
+ }
126
+ break;
127
+ }
128
+ default:
129
+ break;
130
+ }
131
+ });
132
+
133
+ return found
134
+ .sort((left, right) =>
135
+ left.repoIndex !== right.repoIndex
136
+ ? left.repoIndex - right.repoIndex
137
+ : refKey(left.ref) < refKey(right.ref)
138
+ ? -1
139
+ : refKey(left.ref) > refKey(right.ref)
140
+ ? 1
141
+ : 0
142
+ )
143
+ .map(({ ref, exactSpelling }) => ({ ref, exactSpelling }));
144
+ }
145
+
146
+ /** How a repository qualifier matches one catalog repository (no qualifier matches all). */
147
+ private qualifies(qualifier: RepoRef | undefined, repo: CatalogRepo): NameMatch {
148
+ if (qualifier === undefined) return "exact";
149
+ let result: NameMatch = "exact";
150
+ if (qualifier.name !== undefined) {
151
+ if (repo.name === undefined) return undefined;
152
+ result = matchName(qualifier.name, repo.name);
153
+ if (result === undefined) return undefined;
154
+ }
155
+ if (qualifier.location !== undefined) {
156
+ if (repo.location?.identity !== qualifier.location.identity) return undefined;
157
+ }
158
+ return result;
159
+ }
160
+
161
+ /** The repository ref a catalog repository is known as. */
162
+ private knownRepo(repo: CatalogRepo, revision?: string): RepoRef {
163
+ return {
164
+ kind: "repo",
165
+ ...(repo.name === undefined ? {} : { name: repo.name }),
166
+ ...(repo.location === undefined ? {} : { location: repo.location }),
167
+ ...(revision === undefined ? {} : { revision }),
168
+ };
169
+ }
170
+
171
+ /** The recipe ref a catalog recipe is known as, parents and all. */
172
+ private knownRecipe(repo: CatalogRepo, recipe: CatalogRepo["recipes"][number], revision?: string): RecipeRef {
173
+ const namespace: NamespaceRef = {
174
+ kind: "namespace",
175
+ name: recipe.namespace,
176
+ repo: this.knownRepo(repo, revision),
177
+ };
178
+ return {
179
+ kind: "recipe",
180
+ name: recipe.name,
181
+ namespace,
182
+ ...(recipe.description === undefined ? {} : { description: recipe.description }),
183
+ };
184
+ }
185
+
186
+ /** What a candidate carries over to the known ref it matched: its range and query values. */
187
+ private carry(candidate: SousRef, known: SousRef): SousRef {
188
+ return {
189
+ ...known,
190
+ ...(candidate.kind === "recipe" && candidate.range !== undefined
191
+ ? { range: candidate.range }
192
+ : {}),
193
+ ...(candidate.vars === undefined ? {} : { vars: candidate.vars }),
194
+ } as SousRef;
195
+ }
196
+
197
+ /** A repository ref, named by short name or by location. */
198
+ private matchRepo(candidate: RepoRef, repo: CatalogRepo): [SousRef, NameMatch] {
199
+ const known = this.knownRepo(repo);
200
+ if (candidate.name === undefined && candidate.location === undefined) return [known, undefined];
201
+ return [
202
+ { ...known, ...(candidate.vars === undefined ? {} : { vars: candidate.vars }) },
203
+ this.qualifies(candidate, repo),
204
+ ];
205
+ }
206
+
207
+ /**
208
+ * What a browser path names in one repository, through the folder each
209
+ * recipe lives in. The path still carries the branch in front of it (which
210
+ * may itself hold slashes), so every split is tried: the folder of a recipe,
211
+ * or anything inside it, names that recipe; a folder holding recipes of
212
+ * exactly one namespace names that namespace.
213
+ *
214
+ * settleBrowsed("main/recipes/workflow/alpha/SKILL.md")
215
+ * // -> the recipe workflow/alpha, with the revision "main"
216
+ */
217
+ private settleBrowsed(candidate: RepoRef, repo: CatalogRepo): SousRef[] {
218
+ if (candidate.location === undefined || repo.location?.identity !== candidate.location.identity) {
219
+ return [];
220
+ }
221
+ const segments = (candidate.browsed ?? "").split("/").filter((segment) => segment.length > 0);
222
+ const recipes = repo.recipes
223
+ .map((recipe) => ({ recipe, path: (recipe.path ?? "").replace(/^\.?\/+|\/+$/g, "") }))
224
+ .filter((entry) => entry.path.length > 0);
225
+
226
+ const range = candidate.range === undefined ? {} : { range: candidate.range };
227
+ const found = new Map<string, SousRef>();
228
+
229
+ // A recipe's folder, or a file inside it. The deepest recipe wins, so a
230
+ // recipe nested inside another's folder is named by its own path.
231
+ for (let start = 1; start < segments.length; start++) {
232
+ const folder = segments.slice(start).join("/");
233
+ const holding = recipes
234
+ .filter((entry) => folder === entry.path || folder.startsWith(`${entry.path}/`))
235
+ .sort((left, right) => right.path.length - left.path.length);
236
+ const deepest = holding[0];
237
+ if (deepest === undefined) continue;
238
+ const revision = segments.slice(0, start).join("/");
239
+ found.set(`${deepest.recipe.namespace}/${deepest.recipe.name}`, {
240
+ ...this.knownRecipe(repo, deepest.recipe, revision),
241
+ ...range,
242
+ });
243
+ }
244
+ if (found.size > 0) return [...found.values()];
245
+
246
+ // A folder above recipes: a namespace, when everything under it is one.
247
+ for (let start = 1; start < segments.length; start++) {
248
+ const folder = segments.slice(start).join("/");
249
+ const under = recipes.filter((entry) => entry.path.startsWith(`${folder}/`));
250
+ const namespaces = new Set(under.map((entry) => entry.recipe.namespace));
251
+ if (namespaces.size !== 1) continue;
252
+ const name = [...namespaces][0]!;
253
+ found.set(name, {
254
+ kind: "namespace",
255
+ name,
256
+ repo: this.knownRepo(repo, segments.slice(0, start).join("/")),
257
+ });
258
+ }
259
+ return [...found.values()];
260
+ }
261
+ }
@@ -0,0 +1,27 @@
1
+ import type { SousRef } from "../types.js";
2
+ import type { RefLookup, RefMatch } from "./ref-lookup.js";
3
+
4
+ /**
5
+ * Asks several lookups in order. The first one that answers a candidate with a
6
+ * non-empty list wins and the rest are never asked, so a cheap, authoritative
7
+ * source (a release's record) can sit in front of a broad one (every cached
8
+ * index). A lookup that throws stops the chain: a failure is never skipped.
9
+ */
10
+ export class ChainedLookup implements RefLookup {
11
+ private readonly lookups: RefLookup[];
12
+
13
+ /**
14
+ * @param lookups - The lookups to ask, most authoritative first.
15
+ */
16
+ constructor(...lookups: RefLookup[]) {
17
+ this.lookups = lookups;
18
+ }
19
+
20
+ async find(candidate: SousRef): Promise<RefMatch[]> {
21
+ for (const lookup of this.lookups) {
22
+ const matches = await lookup.find(candidate);
23
+ if (matches.length > 0) return matches;
24
+ }
25
+ return [];
26
+ }
27
+ }
@@ -0,0 +1,65 @@
1
+ import type { DefinedVariable } from "../../../lib/vars/definition-source.js";
2
+ import { variableCandidates, type LadderContext } from "../../../lib/vars/ladder.js";
3
+ import { bareName } from "../../../lib/vars/names.js";
4
+ import type { SousRef, VariableRef } from "../types.js";
5
+ import type { RefLookup, RefMatch } from "./ref-lookup.js";
6
+ import { variableRefOf } from "./variable-lookup.js";
7
+
8
+ /**
9
+ * Answers an environment variable name with the variables it answers.
10
+ *
11
+ * A name is held whole and compared in its exact spelling only, because a
12
+ * variable (`apiUrl`) and an environment variable name can differ only in case
13
+ * and the spelling typed is the one meant. The names searched are the one each
14
+ * definition declares (a recipe may bind an existing variable such as
15
+ * `GITHUB_TOKEN`) plus every generated name on the resolution ladder that is
16
+ * actually in use in `.sous/.env` or `.sous/.env.local`; generated names are
17
+ * only searched when in use, because every variable generates several and a
18
+ * project would otherwise be told that a name nothing has ever set names one of
19
+ * its variables.
20
+ */
21
+ export class EnvVarLookup implements RefLookup {
22
+ private readonly byName = new Map<string, VariableRef[]>();
23
+
24
+ /**
25
+ * @param variables - The variable definitions in play.
26
+ * @param ladder - The environment layers, which decide which generated names are in use.
27
+ */
28
+ constructor(variables: DefinedVariable[], ladder?: LadderContext) {
29
+ const inUse =
30
+ ladder === undefined
31
+ ? new Set<string>()
32
+ : new Set([...Object.keys(ladder.localEnv), ...Object.keys(ladder.sharedEnv)]);
33
+
34
+ for (const defined of variables) {
35
+ const names = new Set<string>([bareName(defined.definition)]);
36
+ if (ladder !== undefined) {
37
+ for (const candidate of variableCandidates(defined, ladder)) {
38
+ if (inUse.has(candidate.envName)) names.add(candidate.envName);
39
+ }
40
+ }
41
+ for (const name of names) {
42
+ const answered = this.byName.get(name) ?? [];
43
+ answered.push(variableRefOf(defined));
44
+ this.byName.set(name, answered);
45
+ }
46
+ }
47
+ }
48
+
49
+ async find(candidate: SousRef): Promise<RefMatch[]> {
50
+ if (candidate.kind !== "envVar") return [];
51
+ const variables = this.byName.get(candidate.name);
52
+ if (variables === undefined) return [];
53
+ return [
54
+ {
55
+ ref: {
56
+ kind: "envVar",
57
+ name: candidate.name,
58
+ variables,
59
+ ...(candidate.vars === undefined ? {} : { vars: candidate.vars }),
60
+ },
61
+ exactSpelling: true,
62
+ },
63
+ ];
64
+ }
65
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Settling, at release time, a dependency that reads more than one way.
3
+ *
4
+ * Most dependencies say exactly what they mean: `workflow/alpha`, or
5
+ * `github://owner/repo/workflow/alpha`. Two kinds do not: a GitLab URL with
6
+ * nested groups, which does not say where the project path ends, and a browser
7
+ * URL copied from a host's file view, which names a folder whose recipe only
8
+ * that repository's index can say.
9
+ *
10
+ * `sous repo release` settles each of them once, by fetching the index of every
11
+ * candidate repository through the provider layer and keeping the reading whose
12
+ * index publishes what was named. A network failure fails the release: a
13
+ * release never falls through to the next reading, because the reading it fell
14
+ * through to could be the wrong one. Two readings that both publish what is
15
+ * named are a genuine tie, and an error naming the spelling that reads one way.
16
+ */
17
+
18
+ import { ConfigError } from "../../../lib/errors.js";
19
+ import { INDEX_FILENAME } from "../../../lib/repos/formats/common.js";
20
+ import { parseIndexFile, type IndexFile } from "../../../lib/repos/formats/index-file.js";
21
+ import {
22
+ builtInProviders,
23
+ providerById,
24
+ type ProviderOptions,
25
+ type RepoProvider,
26
+ } from "../../../lib/repos/providers/index.js";
27
+ import { formatRef } from "../format.js";
28
+ import { locationOf } from "../parts.js";
29
+ import type { RefLocation, SousRef } from "../types.js";
30
+ import { CatalogMatcher, catalogRepoOfIndex } from "./catalog-matcher.js";
31
+ import type { RefLookup, RefMatch } from "./ref-lookup.js";
32
+
33
+ /** Fetches the index of the repository at a location. */
34
+ export type IndexFetcher = (location: RefLocation) => Promise<IndexFile>;
35
+
36
+ /** What a `FetchedIndexLookup` may be given. */
37
+ export type FetchedIndexLookupOptions = {
38
+ /** The providers to fetch through. Defaults to the built-ins. */
39
+ providers?: RepoProvider[];
40
+ /** Testing seams handed to every index fetch by the default fetcher. */
41
+ providerOptions?: ProviderOptions;
42
+ /** Replaces the default fetcher, which goes through the provider layer. */
43
+ fetchIndex?: IndexFetcher;
44
+ /** What the ref being settled is called in an error, such as "dependency". Defaults to "ref". */
45
+ subject?: string;
46
+ };
47
+
48
+ /** A failure, in a sentence. */
49
+ function describe(error: unknown): string {
50
+ return error instanceof Error ? error.message : String(error);
51
+ }
52
+
53
+ /**
54
+ * Answers a located candidate by fetching the index of the repository it names
55
+ * (once per repository) and matching the candidate against it. A candidate
56
+ * with no location names no repository to fetch from and matches nothing.
57
+ */
58
+ export class FetchedIndexLookup implements RefLookup {
59
+ private readonly providers: RepoProvider[];
60
+ private readonly fetcher: IndexFetcher;
61
+ private readonly indexes = new Map<string, Promise<IndexFile>>();
62
+
63
+ /**
64
+ * @param options - The providers and testing seams, or a replacement fetcher.
65
+ */
66
+ constructor(private readonly options: FetchedIndexLookupOptions = {}) {
67
+ this.providers = options.providers ?? builtInProviders();
68
+ this.fetcher = options.fetchIndex ?? ((location) => this.fetchThroughProvider(location));
69
+ }
70
+
71
+ async find(candidate: SousRef): Promise<RefMatch[]> {
72
+ const location = locationOf(candidate);
73
+ if (location === undefined) return [];
74
+
75
+ let index: IndexFile;
76
+ try {
77
+ index = await this.indexAt(location);
78
+ } catch (error) {
79
+ const subject = this.options.subject ?? "ref";
80
+ const folder = candidate.kind === "repo" && candidate.browsed !== undefined;
81
+ throw new ConfigError(
82
+ `the ${subject} reads ${folder ? "as a folder in" : "as something in"} the repository ` +
83
+ `at ${location.url}, and its index could not be read, so the release cannot settle ` +
84
+ `what it means. A release never guesses past an index it could not read.\n` +
85
+ ` ${describe(error)}`
86
+ );
87
+ }
88
+
89
+ return new CatalogMatcher([catalogRepoOfIndex(index, { location })]).match(candidate);
90
+ }
91
+
92
+ /**
93
+ * The one reading of a written ref that the fetched indexes confirm: every
94
+ * reading is looked up, and exactly one may publish what it names.
95
+ *
96
+ * @param candidates - Every reading of the written ref.
97
+ * @returns The one match.
98
+ * @throws A ConfigError when no repository publishes it, when several do (naming the
99
+ * `/*` spelling that reads one way), or when an index could not be fetched.
100
+ */
101
+ async settle(candidates: SousRef[]): Promise<RefMatch> {
102
+ const choices: RefMatch[] = [];
103
+ for (const candidate of candidates) choices.push(...(await this.find(candidate)));
104
+
105
+ if (choices.length === 0) {
106
+ throw new ConfigError(
107
+ `no repository it could name publishes what it names. It was read as:\n` +
108
+ candidates.map((candidate) => ` ${formatRef(candidate, this.providers)}`).join("\n")
109
+ );
110
+ }
111
+ if (choices.length > 1) {
112
+ const namespace = choices.find((choice) => choice.ref.kind === "namespace") ?? choices[0]!;
113
+ const written = formatRef(namespace.ref, this.providers);
114
+ throw new ConfigError(
115
+ `it names more than one thing, and each of these publishes it:\n` +
116
+ choices.map((choice) => ` ${formatRef(choice.ref, this.providers)}`).join("\n") +
117
+ `\n Write the one you mean. A namespace is written with '/*' after it, as in ` +
118
+ `'${namespace.ref.kind === "namespace" ? `${written}/*` : written}', and a recipe as ` +
119
+ `its canonical locator above.`
120
+ );
121
+ }
122
+ return choices[0]!;
123
+ }
124
+
125
+ /**
126
+ * The index at a location, fetched once per repository.
127
+ *
128
+ * @param location - Where the repository lives.
129
+ */
130
+ indexAt(location: RefLocation): Promise<IndexFile> {
131
+ let pending = this.indexes.get(location.identity);
132
+ if (pending === undefined) {
133
+ pending = this.fetcher(location);
134
+ this.indexes.set(location.identity, pending);
135
+ }
136
+ return pending;
137
+ }
138
+
139
+ /** Fetches and validates the index at a location, through its provider. */
140
+ private async fetchThroughProvider(location: RefLocation): Promise<IndexFile> {
141
+ const provider = providerById(location.provider, this.providers);
142
+ /* c8 ignore next */
143
+ if (provider === undefined) throw new Error(`sous has no '${location.provider}' provider.`);
144
+ const fetched = await provider.fetchIndex(
145
+ provider.canonicalize(location.url),
146
+ this.options.providerOptions ?? {}
147
+ );
148
+ return parseIndexFile(JSON.parse(fetched.text), `${location.url}/${INDEX_FILENAME}`);
149
+ }
150
+ }
@@ -0,0 +1,62 @@
1
+ import { locationOf } from "../parts.js";
2
+ import type { SousRef } from "../types.js";
3
+ import type { RefLookup, RefMatch } from "./ref-lookup.js";
4
+
5
+ /** What a repository's index recorded about one dependency, as far as settling needs. */
6
+ export type RecordedDependencies = Record<string, { repo?: string } | undefined>;
7
+
8
+ /**
9
+ * Whether what a version's index entry recorded confirms a candidate reading
10
+ * of one of its dependencies. This is the rule `RecordedDependencyLookup`
11
+ * answers with, for callers that settle a dependency without waiting.
12
+ *
13
+ * @param candidate - One reading of the dependency as the manifest wrote it.
14
+ * @param recorded - What the declaring version's index entry records, keyed `namespace/recipe`.
15
+ */
16
+ export function isConfirmedByRecord(
17
+ candidate: SousRef,
18
+ recorded: RecordedDependencies | undefined
19
+ ): boolean {
20
+ const identity = locationOf(candidate)?.identity;
21
+ const records = Object.entries(recorded ?? {}).filter(([, record]) =>
22
+ identity === undefined ? record?.repo === undefined : record?.repo === identity
23
+ );
24
+
25
+ switch (candidate.kind) {
26
+ case "repo":
27
+ return candidate.browsed !== undefined && identity !== undefined && records.length > 0;
28
+ case "namespace":
29
+ return records.some(([key]) => key.startsWith(`${candidate.name}/`));
30
+ case "recipe":
31
+ return (
32
+ candidate.namespace !== undefined &&
33
+ records.some(([key]) => key === `${candidate.namespace!.name}/${candidate.name}`)
34
+ );
35
+ default:
36
+ return false;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Answers from what a published version's index entry recorded about its
42
+ * dependencies, and never searches.
43
+ *
44
+ * A release settles a dependency that reads more than one way (a GitLab URL
45
+ * with nested groups, a browser URL) and records the repository it settled on
46
+ * beside each key it resolved; a consumer reads that record and never probes.
47
+ * A candidate is confirmed when a record says so: a namespace by a record
48
+ * under a key inside it, a recipe by a record under its own key, a browser
49
+ * path by any record carrying its repository. A candidate that names no
50
+ * location is confirmed by a record that names none, which is a sibling in the
51
+ * declaring recipe's own repository.
52
+ */
53
+ export class RecordedDependencyLookup implements RefLookup {
54
+ /**
55
+ * @param recorded - What the declaring version's index entry records, keyed `namespace/recipe`.
56
+ */
57
+ constructor(private readonly recorded: RecordedDependencies | undefined) {}
58
+
59
+ async find(candidate: SousRef): Promise<RefMatch[]> {
60
+ return isConfirmedByRecord(candidate, this.recorded) ? [{ ref: candidate, exactSpelling: true }] : [];
61
+ }
62
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Lookups: what exists, as a queryable object rather than a list.
3
+ *
4
+ * A caller that knows what exists (the cached indexes, a release's recorded
5
+ * dependencies, the variable definitions in play) hands the resolver a lookup,
6
+ * and the candidate readings narrow to the ones that are actually available.
7
+ */
8
+
9
+ import type { SousRef } from "../types.js";
10
+
11
+ /** One thing a candidate reading turned out to name. */
12
+ export type RefMatch = {
13
+ /**
14
+ * The known ref, spelled the way it is published (not the way it was
15
+ * typed), with every parent it has.
16
+ */
17
+ ref: SousRef;
18
+ /**
19
+ * True when every part of the candidate matched in exactly the case it was
20
+ * written. A lookup returns case-insensitive matches too; the resolver keeps
21
+ * them only when nothing matched exactly.
22
+ */
23
+ exactSpelling: boolean;
24
+ };
25
+
26
+ /**
27
+ * Answers what a candidate reading names.
28
+ *
29
+ * `find` returns an empty array when nothing known matches the candidate, and
30
+ * throws when the lookup could not find out (an index that could not be
31
+ * fetched): not finding an answer and failing to look are different things.
32
+ */
33
+ export interface RefLookup {
34
+ /**
35
+ * Every known ref a candidate names.
36
+ *
37
+ * @param candidate - One reading of a written ref.
38
+ */
39
+ find(candidate: SousRef): Promise<RefMatch[]>;
40
+ }
41
+
42
+ /**
43
+ * A lookup that can also answer without waiting, for a caller whose own
44
+ * interface is synchronous (a template engine resolving a path). It answers
45
+ * exactly what `find` answers.
46
+ */
47
+ export interface SyncRefLookup extends RefLookup {
48
+ /**
49
+ * Every known ref a candidate names, at once.
50
+ *
51
+ * @param candidate - One reading of a written ref.
52
+ */
53
+ findSync(candidate: SousRef): RefMatch[];
54
+ }