@sous-io/sous 0.2.28 → 0.2.29

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 (43) hide show
  1. package/docs/markdown/_sidebar.md +1 -0
  2. package/docs/markdown/repositories-authoring.md +12 -8
  3. package/docs/markdown/repositories-consuming.md +8 -1
  4. package/docs/markdown/repositories-file-formats.md +53 -8
  5. package/docs/markdown/repositories-providers.md +8 -6
  6. package/docs/markdown/repositories-troubleshooting.md +6 -0
  7. package/docs/markdown/repositories-variables.md +9 -2
  8. package/docs/markdown/repositories.md +3 -3
  9. package/package.json +1 -1
  10. package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +11 -7
  11. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  12. package/src/commands/lock/rebuild.ts +2 -2
  13. package/src/commands/repo/contribute.ts +1 -10
  14. package/src/commands/repo/gc.ts +3 -2
  15. package/src/commands/repo/release.ts +20 -3
  16. package/src/commands/repo/search.ts +2 -1
  17. package/src/commands/vars/ask.ts +32 -10
  18. package/src/lib/refs/find.ts +202 -21
  19. package/src/lib/refs/index.ts +2 -0
  20. package/src/lib/refs/parse.ts +844 -0
  21. package/src/lib/refs/scopes.ts +34 -0
  22. package/src/lib/refs/settle.ts +219 -0
  23. package/src/lib/repos/catalog.ts +44 -25
  24. package/src/lib/repos/formats/index-file.ts +2 -1
  25. package/src/lib/repos/formats/recipe-manifest.ts +10 -8
  26. package/src/lib/repos/index.ts +0 -1
  27. package/src/lib/repos/lock-service.ts +3 -2
  28. package/src/lib/repos/locked-namespace-resolver.ts +21 -15
  29. package/src/lib/repos/locked-recipes.ts +2 -2
  30. package/src/lib/repos/namespace-resolver.ts +40 -47
  31. package/src/lib/repos/providers/base.ts +30 -0
  32. package/src/lib/repos/providers/github.ts +22 -0
  33. package/src/lib/repos/providers/gitlab.ts +68 -0
  34. package/src/lib/repos/providers/provider.ts +96 -0
  35. package/src/lib/repos/release/index-builder.ts +55 -17
  36. package/src/lib/repos/release/index.ts +1 -0
  37. package/src/lib/repos/release/plan.ts +8 -6
  38. package/src/lib/repos/release/settle.ts +237 -0
  39. package/src/lib/repos/release/tags.ts +3 -7
  40. package/src/lib/repos/resolver.ts +198 -41
  41. package/src/lib/repos/subscription-service.ts +262 -38
  42. package/src/lib/vars/mappings.ts +17 -30
  43. package/src/lib/repos/ref.ts +0 -513
@@ -57,19 +57,32 @@ import {
57
57
  type Lockfile,
58
58
  } from "./formats/lockfile.js";
59
59
  import type { RecipeManifest } from "./formats/recipe-manifest.js";
60
- import { formatRef, parseRef, refKey, type ParsedRef } from "./ref.js";
61
60
  import {
61
+ RefSource,
62
+ Qualification,
62
63
  SousScope,
64
+ formatRef,
65
+ isNamedReading,
66
+ matchNames,
67
+ namespaceOfKey,
68
+ parseRef,
69
+ parseShortRef,
70
+ refKey,
71
+ splitRecipeKey,
72
+ type ParsedRef,
73
+ type RefReading,
63
74
  describeReference,
64
75
  findReference,
65
76
  pickReference,
66
77
  referenceReposFromIndexes,
67
78
  referenceToRef,
68
79
  type ReferenceMatch,
80
+ type ReferenceRepo,
69
81
  } from "../refs/index.js";
70
82
  import { describeIndexSearch } from "./ref-search.js";
71
83
  import {
72
84
  PROJECT_REQUESTER,
85
+ proposeRepoName,
73
86
  resolveRefs,
74
87
  type MissingRepo,
75
88
  type RefRequest,
@@ -639,6 +652,26 @@ export class SubscriptionService {
639
652
  return identity === undefined ? undefined : this.indexCache.readCached(identity);
640
653
  }
641
654
 
655
+ /**
656
+ * Every trusted repository as a reference searches it, read from the cached
657
+ * indexes alone: what it publishes, where each recipe lives, and its
658
+ * canonical identity, so a reference written as a location finds it whatever
659
+ * this project calls it. A repository whose index has never been fetched can
660
+ * still be named, just not searched inside.
661
+ */
662
+ cachedReferenceRepos(): ReferenceRepo[] {
663
+ const repos = this.resolverRepos();
664
+ return Object.keys(repos).map((name) => {
665
+ const { url, identity } = repos[name]!;
666
+ const index = this.cachedIndex(name);
667
+ const base =
668
+ index === undefined
669
+ ? { name, url, namespaces: [], recipes: [] }
670
+ : referenceReposFromIndexes([name], new Map([[name, index]]), { [name]: url })[0]!;
671
+ return { ...base, identity };
672
+ });
673
+ }
674
+
642
675
  // --- Repository identity ----------------------------------------------------------------------
643
676
 
644
677
  /**
@@ -796,27 +829,30 @@ export class SubscriptionService {
796
829
  * @param options - The ref, the prerelease and always-pull flags, and the trust flag.
797
830
  */
798
831
  async subscribe(options: SubscribeOptions): Promise<SubscribeOutcome> {
799
- const written = parseRef(options.ref);
800
832
  const dryRun = options.dryRun === true;
801
833
 
802
- // A one-word ref is a guess at a name, and the guess is settled here, from
803
- // the cached indexes alone. Everything after this point works with a fully
804
- // qualified ref, so what is confirmed is exactly what is installed.
805
- const parsed = await this.resolveBareRef(written, options);
834
+ // What the ref names is settled here, from the cached indexes alone: a
835
+ // one-word ref is a guess at a name, and a location is matched against the
836
+ // repository at that location (trusted first, when it is not yet). Every
837
+ // step after this works with a fully qualified ref, so what is confirmed
838
+ // is exactly what is installed.
839
+ const { parsed, trusted: trustedForRef } = await this.resolveWrittenRef(options.ref, options);
806
840
  const key = refKey(parsed);
807
841
 
808
842
  // The last gate before anything is fetched or written: what this will do to
809
843
  // the project, in plain sentences, and a question.
810
844
  await this.confirmSubscription(parsed, options);
811
845
 
812
- const { resolved, trusted, cycles } = await this.resolveClosure(parsed, options);
846
+ const closure = await this.resolveClosure(parsed, options);
847
+ const { resolved, cycles } = closure;
848
+ const trusted = [...trustedForRef, ...closure.trusted];
813
849
 
814
850
  const before = this.lock.read();
815
851
  const after = this.lock.applyResolution(before, resolved, this.lockRepoInputs());
816
852
  const diff = this.lock.diff(before, after);
817
853
 
818
854
  const resolvedFrom =
819
- formatRef(written) === formatRef(parsed) ? {} : { resolvedFrom: formatRef(written) };
855
+ options.ref.trim() === formatRef(parsed) ? {} : { resolvedFrom: options.ref.trim() };
820
856
 
821
857
  if (dryRun) {
822
858
  // The questions are planned even here, so `--dry-run` is the command an
@@ -881,6 +917,161 @@ export class SubscriptionService {
881
917
 
882
918
  // --- Working out what a one-word ref meant -----------------------------------------------------
883
919
 
920
+ /**
921
+ * Settles what a ref typed on the command line names, in any form it was
922
+ * written: a short ref goes through `resolveBareRef`; a location (a URL, an
923
+ * SSH remote, a provider-scheme locator, a browser URL) is matched against
924
+ * the trusted repository at that location and settled through its index.
925
+ * When no reading's repository is trusted yet, the trust ceremony runs for
926
+ * it first, exactly as it runs for a dependency's repository, and nothing is
927
+ * fetched from it before the answer.
928
+ *
929
+ * @param written - The ref exactly as the user wrote it.
930
+ * @param options - The trust and accept-first flags.
931
+ * @returns The fully qualified ref, and any repository trusted to settle it.
932
+ */
933
+ private async resolveWrittenRef(
934
+ written: string,
935
+ options: SubscribeOptions
936
+ ): Promise<{ parsed: ParsedRef; trusted: string[] }> {
937
+ const readings = parseRef(written, RefSource.CommandLine, this.providers);
938
+ const first = readings[0]!;
939
+ if (first.location === undefined && isNamedReading(first)) {
940
+ return { parsed: await this.resolveBareRef(first, options), trusted: [] };
941
+ }
942
+
943
+ const trusted = await this.trustLocation(written, readings, options);
944
+ const chosen = await this.pickLocated(written, options.acceptFirst);
945
+ const range = "range" in first ? first.range : undefined;
946
+ return {
947
+ parsed: referenceToRef(chosen, {
948
+ namespace: chosen.namespace!,
949
+ ...(range === undefined ? {} : { range }),
950
+ }),
951
+ trusted,
952
+ };
953
+ }
954
+
955
+ /**
956
+ * The one namespace or recipe a location names among the trusted
957
+ * repositories, settled through their cached indexes and chosen between the
958
+ * way every reference is.
959
+ *
960
+ * @param written - The location as written.
961
+ * @param acceptFirst - Whether `--accept-first` was passed.
962
+ */
963
+ private async pickLocated(written: string, acceptFirst?: boolean): Promise<ReferenceMatch> {
964
+ const repoOrder = this.repoSearchOrder();
965
+ const indexes = await this.loadIndexes(repoOrder);
966
+ this.indexesSnapshot = indexes;
967
+
968
+ const matches = findReference(
969
+ written,
970
+ [SousScope.Namespace, SousScope.Recipe],
971
+ { repos: this.referenceRepos(repoOrder, indexes) }
972
+ );
973
+
974
+ return pickReference(matches, {
975
+ search: written.trim(),
976
+ interactive: this.interactive,
977
+ ...(acceptFirst === undefined ? {} : { acceptFirst }),
978
+ details: [
979
+ ` The repository at that location is trusted, but its index publishes no namespace ` +
980
+ `or recipe there.`,
981
+ ` Run 'sous repo list --latest' to read what it publishes now.`,
982
+ ],
983
+ write: (message: string) => this.write(message),
984
+ choose: (message, offered) => this.choose(message, offered),
985
+ });
986
+ }
987
+
988
+ /**
989
+ * The trusted repositories as a reference searches them, each carrying its
990
+ * canonical identity so a location finds it whatever this project calls it.
991
+ *
992
+ * @param repoOrder - The repositories, in search order.
993
+ * @param indexes - Their indexes.
994
+ */
995
+ private referenceRepos(repoOrder: string[], indexes: Map<string, IndexFile>) {
996
+ const repos = this.resolverRepos();
997
+ const urls: Record<string, string | undefined> = {};
998
+ for (const name of repoOrder) urls[name] = repos[name]?.url;
999
+ return referenceReposFromIndexes(repoOrder, indexes, urls).map((repo) => ({
1000
+ ...repo,
1001
+ ...(repos[repo.name] === undefined ? {} : { identity: repos[repo.name]!.identity }),
1002
+ }));
1003
+ }
1004
+
1005
+ /**
1006
+ * Makes sure the repository a located ref names is trusted: nothing happens
1007
+ * when any reading's repository already is. Otherwise the one repository the
1008
+ * location names goes through the trust ceremony; a location that reads as
1009
+ * several repositories (a GitLab URL with nested groups) is first a question
1010
+ * of which one, since nothing may be fetched from a repository to find out
1011
+ * before it is trusted.
1012
+ *
1013
+ * @param written - The location as written.
1014
+ * @param readings - Every reading of it.
1015
+ * @param options - The trust and accept-first flags.
1016
+ * @returns The short names of the repositories trusted here.
1017
+ */
1018
+ private async trustLocation(
1019
+ written: string,
1020
+ readings: RefReading[],
1021
+ options: SubscribeOptions
1022
+ ): Promise<string[]> {
1023
+ const repos = this.resolverRepos();
1024
+ const known = new Set(Object.values(repos).map((repo) => repo.identity));
1025
+ const located = readings.filter((reading) => reading.location !== undefined);
1026
+ if (located.some((reading) => known.has(reading.location!.identity))) return [];
1027
+
1028
+ let chosen = located[0]!;
1029
+ const identities = new Set(located.map((reading) => reading.location!.identity));
1030
+ if (identities.size > 1) {
1031
+ const offered: ReferenceMatch[] = located.map((reading) => ({
1032
+ scope:
1033
+ isNamedReading(reading) && reading.recipe !== undefined
1034
+ ? SousScope.Recipe
1035
+ : SousScope.Namespace,
1036
+ key: formatRef(reading, this.providers),
1037
+ label: formatRef(reading, this.providers),
1038
+ repo: reading.location!.url,
1039
+ ...(isNamedReading(reading) ? { namespace: reading.namespace } : {}),
1040
+ ...(isNamedReading(reading) && reading.recipe !== undefined
1041
+ ? { recipe: reading.recipe }
1042
+ : {}),
1043
+ qualification: Qualification.Full,
1044
+ }));
1045
+ const picked = await pickReference(offered, {
1046
+ search: written.trim(),
1047
+ interactive: this.interactive,
1048
+ ...(options.acceptFirst === undefined ? {} : { acceptFirst: options.acceptFirst }),
1049
+ prompt: `'${written.trim()}' can be read as more than one repository. Which one?`,
1050
+ write: (message: string) => this.write(message),
1051
+ choose: (message, list) => this.choose(message, list),
1052
+ });
1053
+ chosen = located[offered.indexOf(picked)]!;
1054
+ }
1055
+
1056
+ const location = chosen.location!;
1057
+ const outcome = await this.trust.confirmTrust(
1058
+ [
1059
+ {
1060
+ name: proposeRepoName(repos, location.identity),
1061
+ url: location.url,
1062
+ identity: location.identity,
1063
+ provider: location.provider,
1064
+ requiredBy: [{ ref: written.trim(), requestedBy: PROJECT_REQUESTER }],
1065
+ },
1066
+ ],
1067
+ {
1068
+ interactive: this.interactive,
1069
+ ...(options.trust === undefined ? {} : { trustFlag: options.trust }),
1070
+ }
1071
+ );
1072
+ return outcome.added;
1073
+ }
1074
+
884
1075
  /**
885
1076
  * Settles what a ref names, reading nothing but the cached indexes.
886
1077
  *
@@ -898,33 +1089,39 @@ export class SubscriptionService {
898
1089
  written: ParsedRef,
899
1090
  options: SubscribeOptions
900
1091
  ): Promise<ParsedRef> {
901
- if (written.recipe !== undefined) return written;
902
-
903
- const repoOrder = this.repoSearchOrder(written.repo);
1092
+ // A ref that names a recipe, spelled as it is published, says what it
1093
+ // names already. One word, or a spelling in another case, is searched.
1094
+ const search =
1095
+ written.repo === undefined ? refKey(written) : `${written.repo}:${refKey(written)}`;
1096
+ if (written.recipe !== undefined && refKey(written) === refKey(written).toLowerCase()) {
1097
+ return written;
1098
+ }
1099
+
1100
+ const qualifier =
1101
+ written.repo === undefined
1102
+ ? undefined
1103
+ : (matchNames(written.repo, Object.keys(this.currentRepos()))[0] ?? written.repo);
1104
+ const repoOrder = this.repoSearchOrder(qualifier);
904
1105
  const indexes = await this.loadIndexes(repoOrder);
905
1106
  if (written.repo === undefined) this.indexesSnapshot = indexes;
906
1107
 
907
1108
  const context = { repos: referenceReposFromIndexes(repoOrder, indexes) };
908
- const matches = findReference(
909
- written.namespace,
910
- [SousScope.Namespace, SousScope.Recipe],
911
- context
912
- );
1109
+ const matches = findReference(search, [SousScope.Namespace, SousScope.Recipe], context);
913
1110
 
914
1111
  if (matches.length === 0) {
915
1112
  throw new ConfigError(
916
1113
  [
917
- `Nothing called '${written.namespace}' was found: no namespace has that name, ` +
1114
+ `Nothing called '${refKey(written)}' was found: no namespace has that name, ` +
918
1115
  `and no recipe does either.`,
919
- ...describeIndexSearch({ name: written.namespace, repoOrder, indexes }),
920
- ` Run 'sous repo search ${written.namespace}' to look for something like it, or ` +
1116
+ ...describeIndexSearch({ name: refKey(written), repoOrder, indexes }),
1117
+ ` Run 'sous repo search ${refKey(written)}' to look for something like it, or ` +
921
1118
  `'sous repo add <url>' to add the repository that publishes it.`,
922
1119
  ].join("\n")
923
1120
  );
924
1121
  }
925
1122
 
926
1123
  const chosen = await pickReference(matches, {
927
- search: written.namespace,
1124
+ search,
928
1125
  interactive: this.interactive,
929
1126
  ...(options.acceptFirst === undefined ? {} : { acceptFirst: options.acceptFirst }),
930
1127
  write: (message: string) => this.write(message),
@@ -1272,6 +1469,26 @@ export class SubscriptionService {
1272
1469
  }
1273
1470
  }
1274
1471
 
1472
+ /**
1473
+ * The subscription key a ref typed on the command line names. A short ref
1474
+ * names its own key, matched against the keys in force exactly first and then
1475
+ * ignoring case; a location is settled through the trusted repository at that
1476
+ * location, the way `subscribe` settles one.
1477
+ *
1478
+ * @param written - The ref as typed.
1479
+ * @param keys - The subscription keys in force.
1480
+ */
1481
+ private async subscriptionKeyFor(written: string, keys: string[]): Promise<string> {
1482
+ const readings = parseRef(written, RefSource.CommandLine, this.providers);
1483
+ const first = readings[0]!;
1484
+ if (first.location === undefined && isNamedReading(first)) {
1485
+ const key = refKey(first);
1486
+ return matchNames(key, new Set(keys))[0] ?? key;
1487
+ }
1488
+ const chosen = await this.pickLocated(written);
1489
+ return refKey(referenceToRef(chosen, { namespace: chosen.namespace! }));
1490
+ }
1491
+
1275
1492
  /**
1276
1493
  * Removes one subscription and everything that was only there because of it.
1277
1494
  * Removal is refcounted: a recipe another subscription (or another recipe)
@@ -1280,12 +1497,14 @@ export class SubscriptionService {
1280
1497
  * @param options - The ref to unsubscribe from.
1281
1498
  */
1282
1499
  async unsubscribe(options: UnsubscribeOptions): Promise<UnsubscribeOutcome> {
1283
- const parsed = parseRef(options.ref);
1284
- const key = refKey(parsed);
1285
1500
  const dryRun = options.dryRun === true;
1286
1501
 
1287
1502
  const managed = this.readSubscriptionEntries();
1288
1503
  const configured = enabledSubscriptions(this.settings);
1504
+ const key = await this.subscriptionKeyFor(options.ref, [
1505
+ ...Object.keys(managed),
1506
+ ...Object.keys(configured),
1507
+ ]);
1289
1508
  const before = this.lock.read();
1290
1509
  const held = this.keysHeldBySubscription(before, key);
1291
1510
 
@@ -1521,7 +1740,7 @@ export class SubscriptionService {
1521
1740
  for (const key of Object.keys(subscriptions).sort()) {
1522
1741
  let qualifier: string | undefined;
1523
1742
  try {
1524
- qualifier = parseRef(key).repo;
1743
+ qualifier = parseShortRef(key, RefSource.Config).repo;
1525
1744
  } catch {
1526
1745
  // A ref that does not parse cannot name this repository, and reporting
1527
1746
  // it here would bury the removal under an unrelated complaint.
@@ -1974,7 +2193,7 @@ export class SubscriptionService {
1974
2193
  const entry = subscriptions[key]!;
1975
2194
  let request: RefRequest;
1976
2195
  try {
1977
- const parsed = parseRef(key);
2196
+ const parsed = parseShortRef(key, RefSource.Config);
1978
2197
  request = {
1979
2198
  ref: { ...parsed, ...(entry.range === undefined ? {} : { range: entry.range }) },
1980
2199
  requestedBy: PROJECT_REQUESTER,
@@ -2082,7 +2301,7 @@ export class SubscriptionService {
2082
2301
 
2083
2302
  let parsed: ParsedRef;
2084
2303
  try {
2085
- parsed = parseRef(key);
2304
+ parsed = parseShortRef(key, RefSource.Config);
2086
2305
  } catch {
2087
2306
  return false;
2088
2307
  }
@@ -2127,7 +2346,7 @@ export class SubscriptionService {
2127
2346
  ): Lockfile {
2128
2347
  const coverOf = (key: string): string | undefined => {
2129
2348
  try {
2130
- return refKey(parseRef(key));
2349
+ return refKey(parseShortRef(key, RefSource.Config));
2131
2350
  } catch {
2132
2351
  return undefined;
2133
2352
  }
@@ -2217,7 +2436,7 @@ export class SubscriptionService {
2217
2436
  if (scope.kind === "all") return true;
2218
2437
  let parsed: ParsedRef;
2219
2438
  try {
2220
- parsed = parseRef(key);
2439
+ parsed = parseShortRef(key, RefSource.Config);
2221
2440
  } catch {
2222
2441
  return false;
2223
2442
  }
@@ -2311,7 +2530,7 @@ export class SubscriptionService {
2311
2530
  if (entry.repo !== repoName) continue;
2312
2531
  const range = this.effectiveRangeFor(key, entry, subscriptions);
2313
2532
  if (range === undefined) continue;
2314
- const subscription = subscriptions[key] ?? subscriptions[key.split("/")[0]!];
2533
+ const subscription = subscriptions[key] ?? subscriptions[namespaceOfKey(key)];
2315
2534
 
2316
2535
  const found = findNewerInRange({
2317
2536
  index,
@@ -2403,7 +2622,7 @@ export class SubscriptionService {
2403
2622
 
2404
2623
  let parsed: ParsedRef;
2405
2624
  try {
2406
- parsed = parseRef(key);
2625
+ parsed = parseShortRef(key, RefSource.Config);
2407
2626
  } catch (error) {
2408
2627
  report.failed.push({ key, reason: describeError(error) });
2409
2628
  continue;
@@ -2670,7 +2889,7 @@ export class SubscriptionService {
2670
2889
 
2671
2890
  for (const [key, entry] of Object.entries(recipes)) {
2672
2891
  if (entry.repo !== repoName) continue;
2673
- const subscription = subscriptions[key] ?? subscriptions[key.split("/")[0]!];
2892
+ const subscription = subscriptions[key] ?? subscriptions[namespaceOfKey(key)];
2674
2893
 
2675
2894
  // Always-pull re-resolves WITHIN what was declared; it never widens it.
2676
2895
  // A recipe held only through another recipe's `depends` has no
@@ -3062,11 +3281,11 @@ export class SubscriptionService {
3062
3281
  const provider = requireProvider(request.url, request.providerId, this.providers);
3063
3282
  const canonical = provider.canonicalize(request.url);
3064
3283
 
3065
- const namespace = request.key.slice(0, request.key.indexOf("/"));
3284
+ const { namespace, name } = splitRecipeKey(request.key);
3066
3285
  const key: StoreKey = {
3067
3286
  identity: request.identity,
3068
3287
  namespace,
3069
- name: request.key.slice(namespace.length + 1),
3288
+ name,
3070
3289
  version: request.version,
3071
3290
  };
3072
3291
 
@@ -3231,7 +3450,7 @@ export class SubscriptionService {
3231
3450
  entry: LockedRecipe,
3232
3451
  subscriptions: Record<string, SubscriptionEntry>
3233
3452
  ): string | undefined {
3234
- const namespace = key.split("/")[0]!;
3453
+ const namespace = namespaceOfKey(key);
3235
3454
  return effectiveRangeForHolders(key, entry.requestedBy, {
3236
3455
  subscriptionRange: (held) => {
3237
3456
  // A subscription the config no longer declares is not a hold sous can
@@ -3270,15 +3489,20 @@ export class SubscriptionService {
3270
3489
  if (manifest === undefined) return undefined;
3271
3490
 
3272
3491
  for (const dependency of manifest.depends ?? []) {
3273
- let parsed: ParsedRef;
3492
+ // Every reading counts: a dependency that reads more than one way still
3493
+ // declares its range for whichever reading the lockfile settled on.
3494
+ let readings: RefReading[];
3274
3495
  try {
3275
- parsed = parseRef(dependency);
3496
+ readings = parseRef(dependency, RefSource.Manifest, this.providers);
3276
3497
  } catch {
3277
3498
  continue;
3278
3499
  }
3279
- const dependencyKey = refKey(parsed);
3280
- if (dependencyKey !== key && dependencyKey !== namespace) continue;
3281
- return parsed.range ?? "*";
3500
+ for (const reading of readings) {
3501
+ if (!isNamedReading(reading)) continue;
3502
+ const dependencyKey = refKey(reading);
3503
+ if (dependencyKey !== key && dependencyKey !== namespace) continue;
3504
+ return reading.range ?? "*";
3505
+ }
3282
3506
  }
3283
3507
 
3284
3508
  return undefined;
@@ -26,12 +26,9 @@ import {
26
26
  managedLayerHeader,
27
27
  updateManagedLayer,
28
28
  } from "../repos/managed-layer.js";
29
- import {
30
- NAMESPACE_NAME_PATTERN,
31
- RECIPE_NAME_PATTERN,
32
- REPO_NAME_PATTERN,
33
- VARIABLE_NAME_PATTERN,
34
- } from "../repos/formats/patterns.js";
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";
35
32
  import type { DefinedVariable } from "./definition-source.js";
36
33
 
37
34
  /** File name of the machine-written mapping record layer. */
@@ -84,32 +81,22 @@ export function parseMappingTarget(input: string): MappingTarget {
84
81
  const trimmed = typeof input === "string" ? input.trim() : "";
85
82
  if (trimmed.length === 0) fail("a target must not be empty.");
86
83
 
87
- let body = trimmed;
88
- let repo: string | undefined;
89
-
90
- const colon = body.indexOf(":");
91
- if (colon !== -1) {
92
- repo = body.slice(0, colon);
93
- body = body.slice(colon + 1);
94
- if (!REPO_NAME_PATTERN.test(repo)) {
95
- fail(
96
- `the repository qualifier '${repo}' must be lowercase kebab-case: a letter, ` +
97
- "then letters, digits or hyphens."
98
- );
99
- }
84
+ // A target is a recipe ref with the variable after one more slash, so the
85
+ // ref part is read by the one ref parser and held to the stored form.
86
+ const slash = trimmed.lastIndexOf("/");
87
+ const variable = slash === -1 ? "" : trimmed.slice(slash + 1);
88
+ let ref: ParsedRef = { namespace: "" };
89
+ try {
90
+ ref = parseShortRef(trimmed.slice(0, Math.max(slash, 0)), RefSource.CommandLine);
91
+ } catch (error) {
92
+ fail((error as Error).message.split("\n")[0]!.replace(/^Invalid ref '[^']*': /, ""));
100
93
  }
101
-
102
- const segments = body.split("/");
103
- if (segments.length !== 3) {
94
+ const { repo, namespace, recipe } = ref;
95
+ if (recipe === undefined || ref.range !== undefined) {
104
96
  fail("a target names a namespace, a recipe and a variable, joined by slashes.");
105
97
  }
106
-
107
- const [namespace, recipe, variable] = segments as [string, string, string];
108
- if (!NAMESPACE_NAME_PATTERN.test(namespace)) {
109
- fail(`the namespace '${namespace}' must be lowercase kebab-case.`);
110
- }
111
- if (!RECIPE_NAME_PATTERN.test(recipe)) {
112
- fail(`the recipe name '${recipe}' must be lowercase kebab-case.`);
98
+ if (formatRef(ref) !== formatRef(ref).toLowerCase()) {
99
+ fail(`the repository, namespace and recipe in '${formatRef(ref)}' must be lowercase.`);
113
100
  }
114
101
  if (!VARIABLE_NAME_PATTERN.test(variable)) {
115
102
  fail(
@@ -118,7 +105,7 @@ export function parseMappingTarget(input: string): MappingTarget {
118
105
  );
119
106
  }
120
107
 
121
- const parsed: MappingTarget = { namespace, recipe, variable };
108
+ const parsed: MappingTarget = { namespace, recipe: recipe!, variable };
122
109
  if (repo !== undefined) parsed.repo = repo;
123
110
  return parsed;
124
111
  }