@telorun/analyzer 0.43.0 → 0.45.0

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 (47) hide show
  1. package/dist/analysis-registry.d.ts +4 -4
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +3 -3
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/analyzer.js +84 -1
  6. package/dist/builtins.js +7 -7
  7. package/dist/definition-registry.d.ts +49 -22
  8. package/dist/definition-registry.d.ts.map +1 -1
  9. package/dist/definition-registry.js +67 -61
  10. package/dist/index.d.ts +4 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -0
  13. package/dist/loaded-types.d.ts +13 -6
  14. package/dist/loaded-types.d.ts.map +1 -1
  15. package/dist/manifest-loader.d.ts.map +1 -1
  16. package/dist/manifest-loader.js +1 -0
  17. package/dist/module-version-order.d.ts +36 -0
  18. package/dist/module-version-order.d.ts.map +1 -0
  19. package/dist/module-version-order.js +91 -0
  20. package/dist/reconcile-module-versions.d.ts +2 -2
  21. package/dist/reconcile-module-versions.d.ts.map +1 -1
  22. package/dist/reconcile-module-versions.js +69 -79
  23. package/dist/reference-field-map.d.ts +2 -1
  24. package/dist/reference-field-map.d.ts.map +1 -1
  25. package/dist/resolve-schema-ref-kinds.d.ts +62 -0
  26. package/dist/resolve-schema-ref-kinds.d.ts.map +1 -0
  27. package/dist/resolve-schema-ref-kinds.js +81 -0
  28. package/dist/sources/manifest-cache.d.ts +14 -4
  29. package/dist/sources/manifest-cache.d.ts.map +1 -1
  30. package/dist/sources/manifest-cache.js +15 -6
  31. package/dist/sources/versioned-ref.d.ts +34 -0
  32. package/dist/sources/versioned-ref.d.ts.map +1 -0
  33. package/dist/sources/versioned-ref.js +60 -0
  34. package/package.json +2 -2
  35. package/src/analysis-registry.ts +4 -4
  36. package/src/analyzer.ts +85 -2
  37. package/src/builtins.ts +7 -7
  38. package/src/definition-registry.ts +67 -66
  39. package/src/index.ts +10 -0
  40. package/src/loaded-types.ts +13 -6
  41. package/src/manifest-loader.ts +1 -0
  42. package/src/module-version-order.ts +91 -0
  43. package/src/reconcile-module-versions.ts +77 -75
  44. package/src/reference-field-map.ts +2 -1
  45. package/src/resolve-schema-ref-kinds.ts +118 -0
  46. package/src/sources/manifest-cache.ts +26 -8
  47. package/src/sources/versioned-ref.ts +77 -0
@@ -0,0 +1,91 @@
1
+ /** SemVer precedence for module versions — the single ordering rule shared by
2
+ * version reconciliation and by any host deciding whether an import is behind.
3
+ *
4
+ * Pure and dependency-free (no `semver` package), so the analyzer stays
5
+ * browser-safe and the editor can reach the same rule the kernel-side analysis
6
+ * uses. A caller that cannot parse a version must not guess: an OCI digest, a
7
+ * moving tag like `latest`, and a malformed pin all come back `null` rather
8
+ * than being ordered by some weaker fallback. */
9
+
10
+ export interface ParsedModuleVersion {
11
+ major: number;
12
+ minor: number;
13
+ patch: number;
14
+ /** Dot-separated prerelease identifiers, or `null` for a release version. */
15
+ pre: string[] | null;
16
+ }
17
+
18
+ /** Parse `X.Y.Z`, `vX.Y.Z`, or `X.Y.Z-pre.1`. Returns `null` for anything that
19
+ * isn't a plain three-part numeric core — an unparseable version is never
20
+ * silently ordered. */
21
+ export function parseModuleVersion(raw: string | undefined): ParsedModuleVersion | null {
22
+ if (typeof raw !== "string") return null;
23
+ const v = raw.startsWith("v") ? raw.slice(1) : raw;
24
+ const [core, ...preParts] = v.split("-");
25
+ const pre = preParts.length > 0 ? preParts.join("-") : null;
26
+ const segments = core.split(".");
27
+ if (segments.length !== 3) return null;
28
+ const [major, minor, patch] = segments.map((s) => {
29
+ if (!/^\d+$/.test(s)) return NaN;
30
+ return Number(s);
31
+ });
32
+ if ([major, minor, patch].some((n) => Number.isNaN(n))) return null;
33
+ return { major, minor, patch, pre: pre === null ? null : pre.split(".") };
34
+ }
35
+
36
+ /** SemVer precedence: numeric core, then a release outranks a prerelease, then
37
+ * prerelease identifiers compared field-by-field (numeric < non-numeric per
38
+ * spec, shorter set loses when all shared fields are equal). */
39
+ export function compareParsedModuleVersions(
40
+ a: ParsedModuleVersion,
41
+ b: ParsedModuleVersion,
42
+ ): number {
43
+ if (a.major !== b.major) return a.major - b.major;
44
+ if (a.minor !== b.minor) return a.minor - b.minor;
45
+ if (a.patch !== b.patch) return a.patch - b.patch;
46
+ if (a.pre === null && b.pre === null) return 0;
47
+ if (a.pre === null) return 1;
48
+ if (b.pre === null) return -1;
49
+ const len = Math.max(a.pre.length, b.pre.length);
50
+ for (let i = 0; i < len; i++) {
51
+ const ai = a.pre[i];
52
+ const bi = b.pre[i];
53
+ if (ai === undefined) return -1;
54
+ if (bi === undefined) return 1;
55
+ const an = /^\d+$/.test(ai);
56
+ const bn = /^\d+$/.test(bi);
57
+ if (an && bn) {
58
+ const d = Number(ai) - Number(bi);
59
+ if (d !== 0) return d;
60
+ } else if (an !== bn) {
61
+ return an ? -1 : 1;
62
+ } else if (ai !== bi) {
63
+ return ai < bi ? -1 : 1;
64
+ }
65
+ }
66
+ return 0;
67
+ }
68
+
69
+ /** Negative / zero / positive when both versions parse, `null` when either does
70
+ * not. The string-in convenience over {@link compareParsedModuleVersions}. */
71
+ export function compareModuleVersions(a: string, b: string): number | null {
72
+ const left = parseModuleVersion(a);
73
+ const right = parseModuleVersion(b);
74
+ if (!left || !right) return null;
75
+ return compareParsedModuleVersions(left, right);
76
+ }
77
+
78
+ /** True when `candidate` is strictly newer than `current` — the test for
79
+ * whether an import is behind. False when they are equal, when `current` is
80
+ * ahead (a version index can lag the module's own origin, and "upgrading" to
81
+ * what it knows would be a downgrade), or when either side is unparseable. */
82
+ export function isNewerModuleVersion(candidate: string, current: string): boolean {
83
+ return (compareModuleVersions(candidate, current) ?? 0) > 0;
84
+ }
85
+
86
+ /** True when two tags name the same version, tolerating a `v` prefix on either
87
+ * side. Falls back to exact equality for unparseable tags, so a digest still
88
+ * matches itself. */
89
+ export function isSameModuleVersion(a: string, b: string): boolean {
90
+ return a === b || compareModuleVersions(a, b) === 0;
91
+ }
@@ -1,5 +1,10 @@
1
1
  import type { ImportEdge, LoadedModule } from "./loaded-types.js";
2
2
  import { isModuleKind } from "./module-kinds.js";
3
+ import {
4
+ compareParsedModuleVersions,
5
+ parseModuleVersion,
6
+ type ParsedModuleVersion,
7
+ } from "./module-version-order.js";
3
8
  import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
4
9
 
5
10
  const SOURCE = "telo-analyzer";
@@ -17,92 +22,68 @@ export interface VersionReconciliation {
17
22
  diagnostics: AnalysisDiagnostic[];
18
23
  }
19
24
 
20
- interface ParsedVersion {
21
- major: number;
22
- minor: number;
23
- patch: number;
24
- /** Dot-separated prerelease identifiers, or `null` for a release version. */
25
- pre: string[] | null;
26
- }
27
-
28
25
  interface ModuleIdentity {
29
26
  source: string;
30
27
  identity: string;
31
28
  version: string;
32
- parsed: ParsedVersion | null;
29
+ /** `null` for an unparseable version, which forces the group onto the
30
+ * conflict path — we never silently hoist across a version we can't reason
31
+ * about. */
32
+ parsed: ParsedModuleVersion | null;
33
33
  text: string;
34
34
  }
35
35
 
36
- /** Parse `X.Y.Z`, `vX.Y.Z`, or `X.Y.Z-pre.1`. Returns `null` for anything that
37
- * isn't a plain three-part numeric core an unparseable version forces the
38
- * group onto the conflict path (we never silently hoist across a version we
39
- * can't reason about). Pure: no dependency on the `semver` package, so the
40
- * analyzer stays browser-safe and dependency-free. */
41
- function parseVersion(raw: string | undefined): ParsedVersion | null {
42
- if (typeof raw !== "string") return null;
43
- const v = raw.startsWith("v") ? raw.slice(1) : raw;
44
- const [core, ...preParts] = v.split("-");
45
- const pre = preParts.length > 0 ? preParts.join("-") : null;
46
- const segments = core.split(".");
47
- if (segments.length !== 3) return null;
48
- const [major, minor, patch] = segments.map((s) => {
49
- if (!/^\d+$/.test(s)) return NaN;
50
- return Number(s);
51
- });
52
- if ([major, minor, patch].some((n) => Number.isNaN(n))) return null;
53
- return { major, minor, patch, pre: pre === null ? null : pre.split(".") };
54
- }
55
-
56
- /** SemVer precedence: numeric core, then a release outranks a prerelease, then
57
- * prerelease identifiers compared field-by-field (numeric < non-numeric per
58
- * spec, shorter set loses when all shared fields are equal). */
59
- function compareVersions(a: ParsedVersion, b: ParsedVersion): number {
60
- if (a.major !== b.major) return a.major - b.major;
61
- if (a.minor !== b.minor) return a.minor - b.minor;
62
- if (a.patch !== b.patch) return a.patch - b.patch;
63
- if (a.pre === null && b.pre === null) return 0;
64
- if (a.pre === null) return 1;
65
- if (b.pre === null) return -1;
66
- const len = Math.max(a.pre.length, b.pre.length);
67
- for (let i = 0; i < len; i++) {
68
- const ai = a.pre[i];
69
- const bi = b.pre[i];
70
- if (ai === undefined) return -1;
71
- if (bi === undefined) return 1;
72
- const an = /^\d+$/.test(ai);
73
- const bn = /^\d+$/.test(bi);
74
- if (an && bn) {
75
- const d = Number(ai) - Number(bi);
76
- if (d !== 0) return d;
77
- } else if (an !== bn) {
78
- return an ? -1 : 1;
79
- } else if (ai !== bi) {
80
- return ai < bi ? -1 : 1;
81
- }
36
+ /** The location identity of an import ref: the ref with its version stripped.
37
+ * Two refs share an identity when they address the same module at different
38
+ * versions, whatever transport owns them:
39
+ *
40
+ * "std/kv-store@0.3.0" → "std/kv-store"
41
+ * "oci://ghcr.io/acme/s3@1.2.0" → "oci://ghcr.io/acme/s3"
42
+ * "https://x.com/lib/telo.yaml" → itself (a URL carries no version)
43
+ *
44
+ * Returns `null` for a relative path, which addresses one file on the
45
+ * publisher's disk and is therefore not a cross-import key: two local libraries
46
+ * that merely agree on `metadata.name` are distinct modules, and reconciling
47
+ * them would drop one and break its kinds. The same local file reached via two
48
+ * paths is already collapsed by canonical-source dedup.
49
+ *
50
+ * **What this key cannot relate.** It compares ref *spellings*, so it groups by
51
+ * origin exactly and nothing else. Two consequences, both accepted:
52
+ *
53
+ * - A module imported once by a registry ref and once by a relative path is two
54
+ * groups, so a version skew between them is not hoisted. Keying on what the
55
+ * module declares about itself would catch that case, but only by trusting a
56
+ * self-declared identity which is what this change removes, and which
57
+ * cannot tell two same-named modules from different origins apart.
58
+ * - A bare `std/kv-store@0.4.0` and the equivalent direct
59
+ * `https://<registry>/std/kv-store/0.4.0/telo.yaml` are two groups. Relating
60
+ * them needs the configured registry base, which this pure, browser-safe
61
+ * function does not have. */
62
+ function refIdentity(ref: string): string | null {
63
+ const base = ref.split("#")[0];
64
+ if (!base || base.startsWith(".") || base.startsWith("/") || base.startsWith("file:")) {
65
+ return null;
82
66
  }
83
- return 0;
67
+ const lastSlash = base.lastIndexOf("/");
68
+ const at = base.lastIndexOf("@");
69
+ return at > lastSlash && at > 0 ? base.slice(0, at) : base;
84
70
  }
85
71
 
86
- /** Read a loaded module's `namespace/name` identity, version, and raw owner
87
- * text. Returns `null` for modules without a namespace: only a registry
88
- * identity (`<namespace>/<name>`) is a stable cross-import key. Two namespace-
89
- * less local libraries that merely share a `metadata.name` are distinct modules
90
- * reached via distinct source URLs reconciling them would drop one and break
91
- * its kinds; the same local file reached via two paths is already collapsed by
92
- * canonical-source dedup, so there is nothing left to reconcile here. */
93
- function moduleIdentityOf(mod: LoadedModule): ModuleIdentity | null {
72
+ /** Read a loaded module's version and raw owner text under the location
73
+ * identity the import edge reached it by. The identity comes from the ref, not
74
+ * from anything the module declares about itself a module's own metadata
75
+ * cannot distinguish two same-named modules published to different origins. */
76
+ function moduleIdentityOf(mod: LoadedModule, identity: string): ModuleIdentity | null {
94
77
  const doc = mod.owner.manifests.find((m) => m && isModuleKind(m.kind));
95
78
  if (!doc) return null;
96
- const meta = doc.metadata as { name?: string; namespace?: string | null; version?: string };
97
- const name = meta?.name;
98
- if (typeof name !== "string" || name.length === 0) return null;
99
- if (typeof meta.namespace !== "string" || meta.namespace.length === 0) return null;
79
+ const meta = doc.metadata as { name?: string; version?: string };
80
+ if (typeof meta?.name !== "string" || meta.name.length === 0) return null;
100
81
  const version = typeof meta.version === "string" ? meta.version : "";
101
82
  return {
102
83
  source: mod.owner.source,
103
- identity: `${meta.namespace}/${name}`,
84
+ identity,
104
85
  version,
105
- parsed: parseVersion(version),
86
+ parsed: parseModuleVersion(version),
106
87
  text: mod.owner.text,
107
88
  };
108
89
  }
@@ -127,7 +108,7 @@ function resolveGroup(members: ModuleIdentity[]): GroupResolution {
127
108
  const winner = members.reduce((best, cur) => {
128
109
  if (!cur.parsed) return best;
129
110
  if (!best.parsed) return cur;
130
- const cmp = compareVersions(cur.parsed, best.parsed);
111
+ const cmp = compareParsedModuleVersions(cur.parsed, best.parsed);
131
112
  if (cmp > 0) return cur;
132
113
  if (cmp === 0 && cur.source < best.source) return cur;
133
114
  return best;
@@ -184,8 +165,8 @@ function hoistDiagnostic(
184
165
  }
185
166
 
186
167
  /**
187
- * Reconcile a loaded import graph so each module identity (`namespace/name`)
188
- * resolves to a single version. Within a shared major the highest version wins
168
+ * Reconcile a loaded import graph so each module location (an import ref minus
169
+ * its version) resolves to a single version. Within a shared major the highest version wins
189
170
  * (a non-lossy hoist, given Telo's additive-only pre-1.0 policy); a major
190
171
  * mismatch is a hard conflict. Mutates `importEdges` in place — every edge that
191
172
  * pointed at a losing source is repointed at the winner — so `flattenForAnalyzer`
@@ -199,10 +180,31 @@ export function reconcileModuleVersions(
199
180
  const overrides = new Map<string, string>();
200
181
  const diagnostics: AnalysisDiagnostic[] = [];
201
182
 
183
+ // Location identity per resolved module, taken from the ref that reached it.
184
+ // Two refs at different versions resolve to different canonical sources, so a
185
+ // source normally maps to exactly one identity; the entry module has no
186
+ // inbound edge and needs none (it is never reconciled against itself).
187
+ //
188
+ // One source CAN be reached by two spellings of the same location (a bare
189
+ // registry ref and the direct URL it resolves to). Both name the same module
190
+ // at the same version, so either identity groups it correctly — but the choice
191
+ // must not depend on edge iteration order, or the same graph could reconcile
192
+ // differently across runs. First edge wins.
193
+ const identityBySource = new Map<string, string>();
194
+ for (const aliasMap of importEdges.values()) {
195
+ for (const edge of aliasMap.values()) {
196
+ if (identityBySource.has(edge.targetSource)) continue;
197
+ const identity = refIdentity(edge.targetRef);
198
+ if (identity) identityBySource.set(edge.targetSource, identity);
199
+ }
200
+ }
201
+
202
202
  const groups = new Map<string, ModuleIdentity[]>();
203
203
  const infoBySource = new Map<string, ModuleIdentity>();
204
- for (const mod of modules.values()) {
205
- const info = moduleIdentityOf(mod);
204
+ for (const [source, mod] of modules) {
205
+ const identity = identityBySource.get(source);
206
+ if (!identity) continue;
207
+ const info = moduleIdentityOf(mod, identity);
206
208
  if (!info) continue;
207
209
  infoBySource.set(info.source, info);
208
210
  const list = groups.get(info.identity);
@@ -1,6 +1,7 @@
1
1
  /** An entry for a field that carries one or more x-telo-ref constraints. */
2
2
  export interface RefFieldEntry {
3
- /** One or more canonical ref strings ("namespace/module#TypeName" or "telo#TypeName").
3
+ /** One or more canonical kind keys ("<module>.<Kind>"), or the deprecated
4
+ * identity form ("<namespace>/<module>#<Kind>") for a legacy published module.
4
5
  * Multiple entries arise from anyOf branches. */
5
6
  refs: string[];
6
7
  /** True when the field path traversed through at least one array (path contains "[]"). */
@@ -0,0 +1,118 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import type { AliasResolver } from "./alias-resolver.js";
3
+
4
+ const REF_ANNOTATION = "x-telo-ref";
5
+
6
+ /** Why an `x-telo-ref` constraint did not canonicalize.
7
+ *
8
+ * - `legacy` — the deprecated `<namespace>/<module>#<Kind>` identity form,
9
+ * still resolved through the identity table.
10
+ * - `unknown` — the prefix is not an alias in the declaring module's scope: a
11
+ * typo, a missing `imports:` entry — or a value that was already
12
+ * canonical, which the caller separates by asking the registry.
13
+ * - `gated` — the alias is known and the target owns the kind, but its
14
+ * `exports.kinds` does not list it. */
15
+ export type RefConstraintReason = "legacy" | "unknown" | "gated";
16
+
17
+ /** An `x-telo-ref` constraint that did not canonicalize. */
18
+ export interface RefConstraintIssue {
19
+ /** The constraint string exactly as authored. */
20
+ ref: string;
21
+ /** Dotted path to the annotated schema node within the doc, e.g.
22
+ * `schema.properties.store`. Points the author at the slot, not just the doc. */
23
+ path: string;
24
+ /** The `Telo.Definition` / `Telo.Abstract` doc that declares the slot. */
25
+ manifest: ResourceManifest;
26
+ reason: RefConstraintReason;
27
+ /** For `gated`: the target module and the kinds it does export. */
28
+ gate?: { module: string; exported: string[] };
29
+ /** Aliases the declaring scope does know — the "did you mean" material for
30
+ * an `unknown` prefix. */
31
+ knownAliases?: string[];
32
+ }
33
+
34
+ /** True for the legacy identity form, which is split on `#` by the identity table. */
35
+ export function isLegacyRefIdentity(ref: string): boolean {
36
+ return ref.includes("#");
37
+ }
38
+
39
+ /**
40
+ * Rewrites the `x-telo-ref` constraints on one definition doc from the alias
41
+ * form (`KvStore.Store`, `Self.Store`, `Telo.Invocable`) to the canonical
42
+ * `<module>.<Kind>` key the definition registry is keyed by.
43
+ *
44
+ * `resolver` must be the scope of the module that DECLARES the definition, not
45
+ * the consumer's: an imported library names its dependencies by its own aliases.
46
+ * This mirrors how `extends:` and `capability:` are pre-resolved before
47
+ * registration, so the registry never needs module context to answer a ref query.
48
+ *
49
+ * Every constraint that does not canonicalize is returned, tagged with why. That
50
+ * matters more than it looks: an unresolved constraint leaves a string naming no
51
+ * registered kind, and the reference check treats an unknown target as partial
52
+ * context and skips it — so an unreported one would silently let the slot accept
53
+ * anything. The authored value is left in place either way, so a diagnostic
54
+ * quotes what the author actually wrote.
55
+ *
56
+ * An `unknown` result also covers an already-canonical value (`kv-store.Store`
57
+ * names a module, not an alias), which is what keeps the rewrite idempotent —
58
+ * the caller drops those by checking the definition registry once every kind is
59
+ * registered.
60
+ *
61
+ * The walk covers the whole doc rather than a fixed field list: a constraint can
62
+ * sit in `schema`, `inputType`, `outputType`, or a `$defs` entry nested in any of
63
+ * them, and rewriting one that appears in a template body's inline schema is
64
+ * correct too.
65
+ */
66
+ export function resolveSchemaRefKinds(
67
+ definition: ResourceManifest,
68
+ resolver: Pick<AliasResolver, "resolveKindResult" | "knownAliases">,
69
+ ): RefConstraintIssue[] {
70
+ const issues: RefConstraintIssue[] = [];
71
+
72
+ const record = (ref: string, path: string): void => {
73
+ if (isLegacyRefIdentity(ref)) {
74
+ issues.push({ ref, path, manifest: definition, reason: "legacy" });
75
+ return;
76
+ }
77
+ const result = resolver.resolveKindResult(ref);
78
+ if (result.status === "ok") return;
79
+ issues.push(
80
+ result.status === "gated"
81
+ ? {
82
+ ref,
83
+ path,
84
+ manifest: definition,
85
+ reason: "gated",
86
+ gate: { module: result.module, exported: result.exported },
87
+ }
88
+ : {
89
+ ref,
90
+ path,
91
+ manifest: definition,
92
+ reason: "unknown",
93
+ knownAliases: resolver.knownAliases(),
94
+ },
95
+ );
96
+ };
97
+
98
+ const walk = (value: unknown, path: string): void => {
99
+ if (value === null || typeof value !== "object") return;
100
+ if (Array.isArray(value)) {
101
+ value.forEach((item, i) => walk(item, `${path}[${i}]`));
102
+ return;
103
+ }
104
+ const obj = value as Record<string, unknown>;
105
+ const ref = obj[REF_ANNOTATION];
106
+ if (typeof ref === "string" && ref) {
107
+ const result = isLegacyRefIdentity(ref) ? null : resolver.resolveKindResult(ref);
108
+ if (result?.status === "ok") obj[REF_ANNOTATION] = result.kind;
109
+ else record(ref, path);
110
+ }
111
+ for (const key of Object.keys(obj)) {
112
+ walk(obj[key], path ? `${path}.${key}` : key);
113
+ }
114
+ };
115
+
116
+ walk(definition, "");
117
+ return issues;
118
+ }
@@ -12,12 +12,20 @@ export const MANIFEST_CACHE_BASE_URL = "https://manifests.telo.sh";
12
12
  * stores per version — `{ transport, host, path, version }` — so the tracker's
13
13
  * write key and the editor's read key come from the same function and never
14
14
  * drift. `path` is the slash-separated repo/module path (multi-segment OCI
15
- * repos nest as prefixes). */
15
+ * repos nest as prefixes), empty for a module addressed at a host's root. */
16
16
  export interface ManifestCacheCoords {
17
17
  transport: string;
18
18
  host: string;
19
19
  path: string;
20
- version: string;
20
+ /** Omit only when the ref carries no version to key by — a direct `https://`
21
+ * URL addresses exactly one file, so its path alone is already unambiguous.
22
+ * Every ref whose grammar has a version segment must supply it, or two
23
+ * versions of one module would collide on a single key. */
24
+ version?: string;
25
+ /** File within the version directory. Defaults to the module manifest, which
26
+ * is the only file the hub's bucket stores; the local install cache also
27
+ * holds each `include:` partial, which is named here. */
28
+ file?: string;
21
29
  }
22
30
 
23
31
  /** True for a segment that would corrupt or escape the cache key space. */
@@ -26,15 +34,25 @@ function invalidSegment(segment: string): boolean {
26
34
  }
27
35
 
28
36
  /** Deterministic cache key for one module version:
29
- * `<transport>/<host>/<path…>/<version>/telo.yaml`. Returns `null` when any
30
- * coordinate is empty or would traverse out of the key space. */
37
+ * `<transport>/<host>/<path…>/<version>/<file>`, where `version` is omitted
38
+ * when the coordinates carry none and `file` defaults to the module manifest.
39
+ * Returns `null` when any coordinate is empty or would traverse out of the key
40
+ * space. */
31
41
  export function manifestCacheKey(coords: ManifestCacheCoords): string | null {
32
- const { transport, host, path, version } = coords;
33
- const segments = [transport, host, ...path.split("/"), version];
34
- if (segments.some(invalidSegment) || transport.includes("/") || host.includes("/") || version.includes("/")) {
42
+ const { transport, host, path, version, file } = coords;
43
+ const segments = [transport, host, ...(path ? path.split("/") : [])];
44
+ if (version !== undefined) segments.push(version);
45
+ segments.push(file ?? DEFAULT_MANIFEST_FILENAME);
46
+ if (
47
+ segments.some(invalidSegment) ||
48
+ transport.includes("/") ||
49
+ host.includes("/") ||
50
+ (version !== undefined && version.includes("/")) ||
51
+ (file !== undefined && file.includes("/"))
52
+ ) {
35
53
  return null;
36
54
  }
37
- return `${segments.join("/")}/${DEFAULT_MANIFEST_FILENAME}`;
55
+ return segments.join("/");
38
56
  }
39
57
 
40
58
  /** Cache coordinates for an `oci://host/repo@tag` ref. Returns `null` when the
@@ -0,0 +1,77 @@
1
+ import { splitIntegrity } from "./integrity.js";
2
+ import { isRegistryRef } from "./module-ref.js";
3
+ import { OCI_SCHEME } from "./oci-ref.js";
4
+
5
+ /** A module ref split into the parts an upgrade needs: the version-independent
6
+ * ref, the version segment it currently names, and any inline pin. */
7
+ export interface ParsedVersionedRef {
8
+ /** The ref with its `@version` segment and integrity fragment removed —
9
+ * `std/run`, `oci://ghcr.io/telorun/timer`. This is the identity a version
10
+ * list is keyed by (the hub registers modules under exactly this form). */
11
+ baseRef: string;
12
+ /** The version segment, raw — a registry `@version`, an OCI tag, or an OCI
13
+ * digest reference (`sha256:…`). The caller applies its own SemVer check. */
14
+ version: string;
15
+ /** Telo's inline `sha256-<base64url>` pin, when the ref carried one. */
16
+ integrity?: string;
17
+ }
18
+
19
+ /** Split a versioned module ref. Returns `null` when the ref names no
20
+ * upgradeable version — a local path, a bare `https://` URL, or an OCI ref
21
+ * with no explicit reference (an implicit `latest` is not a pin).
22
+ *
23
+ * Browser-safe and transport-neutral: this is the *grammar* half of an
24
+ * upgrade, shared by the kernel transports (whose `refVersion` / `withVersion`
25
+ * delegate here) and the editor, which cannot use a transport at all — the
26
+ * *network* half (enumerating versions) is scheme-specific and stays behind
27
+ * `Transport.listVersions` on Node and the hub's `/module/versions` in the
28
+ * browser. */
29
+ export function parseVersionedRef(ref: string): ParsedVersionedRef | null {
30
+ const { base, integrity } = splitIntegrity(ref);
31
+ const at = versionSeparator(base);
32
+ if (at === null) return null;
33
+ const version = base.slice(at + 1);
34
+ if (!version) return null;
35
+ return { baseRef: base.slice(0, at), version, integrity };
36
+ }
37
+
38
+ /** `ref` rewritten to name `version`, dropping any integrity fragment (the
39
+ * caller re-pins the result). Appends the version when the ref carries none —
40
+ * an untagged `oci://host/repo` is still a versionable address.
41
+ *
42
+ * Throws when the ref's grammar has no version segment at all — a relative
43
+ * path, a bare `https://` URL. Producing `../lib@0.4.0` for those would write
44
+ * a ref nothing can resolve, so this fails where the transport-specific
45
+ * parsers it replaced (`parseOciRef` / `parseModuleRef`) also failed. */
46
+ export function withRefVersion(ref: string, version: string): string {
47
+ const { base } = splitIntegrity(ref);
48
+ if (refGrammar(base) === null) {
49
+ throw new Error(
50
+ `Cannot set a version on '${ref}' — only registry (namespace/name@version) ` +
51
+ `and oci:// refs carry a version segment.`,
52
+ );
53
+ }
54
+ const at = versionSeparator(base);
55
+ return `${at === null ? base : base.slice(0, at)}@${version}`;
56
+ }
57
+
58
+ /** Which versionable ref grammar `base` is written in, or `null` when it is
59
+ * neither — a relative/absolute path, a `file:`/`https://` URL. */
60
+ function refGrammar(base: string): "oci" | "registry" | null {
61
+ if (base.startsWith(OCI_SCHEME)) {
62
+ // A host alone is not addressable; the repo path is what carries a version.
63
+ return base.indexOf("/", OCI_SCHEME.length) > OCI_SCHEME.length ? "oci" : null;
64
+ }
65
+ // `isRegistryRef` requires the `@`, so a version-less `std/console` is not a
66
+ // registry ref by this test — matching `parseModuleRef`, which throws on it.
67
+ return isRegistryRef(base) ? "registry" : null;
68
+ }
69
+
70
+ /** Index of the `@` that separates the version, or `null` when the ref names
71
+ * none. Split on the LAST `@` so a digest reference (`repo@sha256:…`) keeps
72
+ * everything before it as the ref. */
73
+ function versionSeparator(base: string): number | null {
74
+ if (refGrammar(base) === null) return null;
75
+ const at = base.lastIndexOf("@");
76
+ return at > (base.startsWith(OCI_SCHEME) ? OCI_SCHEME.length : 0) ? at : null;
77
+ }