@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.
- package/dist/analysis-registry.d.ts +4 -4
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +3 -3
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +84 -1
- package/dist/builtins.js +7 -7
- package/dist/definition-registry.d.ts +49 -22
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +67 -61
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/loaded-types.d.ts +13 -6
- package/dist/loaded-types.d.ts.map +1 -1
- package/dist/manifest-loader.d.ts.map +1 -1
- package/dist/manifest-loader.js +1 -0
- package/dist/module-version-order.d.ts +36 -0
- package/dist/module-version-order.d.ts.map +1 -0
- package/dist/module-version-order.js +91 -0
- package/dist/reconcile-module-versions.d.ts +2 -2
- package/dist/reconcile-module-versions.d.ts.map +1 -1
- package/dist/reconcile-module-versions.js +69 -79
- package/dist/reference-field-map.d.ts +2 -1
- package/dist/reference-field-map.d.ts.map +1 -1
- package/dist/resolve-schema-ref-kinds.d.ts +62 -0
- package/dist/resolve-schema-ref-kinds.d.ts.map +1 -0
- package/dist/resolve-schema-ref-kinds.js +81 -0
- package/dist/sources/manifest-cache.d.ts +14 -4
- package/dist/sources/manifest-cache.d.ts.map +1 -1
- package/dist/sources/manifest-cache.js +15 -6
- package/dist/sources/versioned-ref.d.ts +34 -0
- package/dist/sources/versioned-ref.d.ts.map +1 -0
- package/dist/sources/versioned-ref.js +60 -0
- package/package.json +2 -2
- package/src/analysis-registry.ts +4 -4
- package/src/analyzer.ts +85 -2
- package/src/builtins.ts +7 -7
- package/src/definition-registry.ts +67 -66
- package/src/index.ts +10 -0
- package/src/loaded-types.ts +13 -6
- package/src/manifest-loader.ts +1 -0
- package/src/module-version-order.ts +91 -0
- package/src/reconcile-module-versions.ts +77 -75
- package/src/reference-field-map.ts +2 -1
- package/src/resolve-schema-ref-kinds.ts +118 -0
- package/src/sources/manifest-cache.ts +26 -8
- 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
|
-
|
|
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
|
-
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
|
|
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;
|
|
97
|
-
|
|
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
|
|
84
|
+
identity,
|
|
104
85
|
version,
|
|
105
|
-
parsed:
|
|
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 =
|
|
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
|
|
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
|
|
205
|
-
const
|
|
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
|
|
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
|
|
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
|
|
30
|
-
*
|
|
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("/")
|
|
34
|
-
if (
|
|
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
|
|
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
|
+
}
|