@telorun/analyzer 0.43.0 → 0.44.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/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/reconcile-module-versions.d.ts +2 -2
- package/dist/reconcile-module-versions.d.ts.map +1 -1
- package/dist/reconcile-module-versions.js +69 -17
- 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/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/loaded-types.ts +13 -6
- package/src/manifest-loader.ts +1 -0
- package/src/reconcile-module-versions.ts +69 -17
- 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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { canonicalTypeSchemaId } from "@telorun/sdk";
|
|
2
3
|
import type { AliasResolver } from "./alias-resolver.js";
|
|
3
4
|
import { KERNEL_BUILTINS } from "./builtins.js";
|
|
4
5
|
import {
|
|
@@ -21,18 +22,21 @@ export class DefinitionRegistry {
|
|
|
21
22
|
* across analyze() calls and no unbounded growth across the process lifetime. */
|
|
22
23
|
private readonly ajv = createAjv();
|
|
23
24
|
private readonly registeredSchemaIds = new Set<string>();
|
|
25
|
+
/** The subset of `registeredSchemaIds` claimed by a kind's schema. Kinds and
|
|
26
|
+
* named `Telo.Type`s share one `telo://<module>/<Name>` id space, so this is
|
|
27
|
+
* what lets a colliding type name be reported instead of silently dropped. */
|
|
28
|
+
private readonly definitionSchemaIds = new Set<string>();
|
|
24
29
|
|
|
25
30
|
private readonly defs = new Map<string, ResourceDefinition>();
|
|
26
31
|
private readonly fieldMaps = new Map<string, ReferenceFieldMap>();
|
|
27
32
|
/** Reverse inheritance index: parent kind → direct child kinds. */
|
|
28
33
|
private readonly extendedBy = new Map<string, string[]>();
|
|
29
|
-
/**
|
|
30
|
-
* "
|
|
34
|
+
/** DEPRECATED module identity table: identity string → canonical module name
|
|
35
|
+
* ("std/pipeline" → "pipeline"). Serves only the legacy
|
|
36
|
+
* `<namespace>/<module>#<Kind>` form of `x-telo-ref`, kept resolvable for
|
|
37
|
+
* module versions published before constraints named their target by import
|
|
38
|
+
* alias. Fed by `metadata.namespace`, which nothing else reads. */
|
|
31
39
|
private readonly identityMap = new Map<string, string>();
|
|
32
|
-
/** Reverse identity table: canonical module name → full identity string.
|
|
33
|
-
* "Telo" → "telo", "pipeline" → "std/pipeline", etc.
|
|
34
|
-
* Used to compute definition $id values for the AJV schema store. */
|
|
35
|
-
private readonly reverseIdentityMap = new Map<string, string>();
|
|
36
40
|
|
|
37
41
|
register(definition: ResourceDefinition): void {
|
|
38
42
|
const { name, module: mod } = definition.metadata;
|
|
@@ -60,12 +64,11 @@ export class DefinitionRegistry {
|
|
|
60
64
|
if (definition.extends) {
|
|
61
65
|
this.addExtendedBy(definition.extends, key);
|
|
62
66
|
}
|
|
63
|
-
// Auto-register the telo identity when any Telo built-in is registered
|
|
67
|
+
// Auto-register the legacy telo identity when any Telo built-in is registered,
|
|
68
|
+
// so an already-published `x-telo-ref: "telo#Invocable"` still resolves.
|
|
64
69
|
if (definition.kind === "Telo.Abstract" && mod === "Telo") {
|
|
65
70
|
this.identityMap.set("telo", "Telo");
|
|
66
|
-
this.reverseIdentityMap.set("Telo", "telo");
|
|
67
71
|
}
|
|
68
|
-
// If identity is already known, register the schema in AJV immediately.
|
|
69
72
|
if (mod && definition.schema) {
|
|
70
73
|
this.tryRegisterSchema(mod, name as string, definition.schema as Record<string, any>);
|
|
71
74
|
}
|
|
@@ -80,50 +83,42 @@ export class DefinitionRegistry {
|
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
/** Register a module identity
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
86
|
+
/** DEPRECATED. Register a module identity so the legacy
|
|
87
|
+
* `<namespace>/<module>#<Kind>` form of `x-telo-ref` still resolves for module
|
|
88
|
+
* versions published before constraints named their target by import alias.
|
|
89
|
+
* New manifests declare no namespace and need no identity — their constraints
|
|
90
|
+
* are canonicalized to `<module>.<Kind>` before registration.
|
|
91
|
+
*
|
|
92
|
+
* The "telo" identity is reserved for the built-in module and is populated
|
|
93
|
+
* automatically when a `Telo.Abstract` registers. A namespace-less module must
|
|
94
|
+
* not claim it: overwriting the entry would repoint every legacy `telo#…`
|
|
95
|
+
* constraint at a module that declares no such kind, and the resulting
|
|
96
|
+
* unresolvable ref reads as partial context rather than an error.
|
|
97
|
+
*
|
|
98
|
+
* @param namespace The module's `metadata.namespace`, or null when it declares none.
|
|
99
|
+
* @param moduleName The module's `metadata.name` (e.g. "pipeline", "http-server"). */
|
|
87
100
|
registerModuleIdentity(namespace: string | null, moduleName: string): void {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
// `register` below). A user app / library without a namespace must NOT
|
|
91
|
-
// claim it — silently overwriting the built-in entry breaks every
|
|
92
|
-
// x-telo-ref that resolves through "telo#…". Concretely, the
|
|
93
|
-
// `Http.Api.routes[].handler` slot in the http-server schema carries
|
|
94
|
-
// `x-telo-ref: "telo#Invocable"`. If the entry application is, say,
|
|
95
|
-
// `Telo.Application/HelloApi` (no namespace), this method previously
|
|
96
|
-
// overwrote `"telo" → "Telo"` with `"telo" → "HelloApi"`. The handler's
|
|
97
|
-
// ref then resolved to a nonexistent `HelloApi.Invocable`, the
|
|
98
|
-
// kind-mismatch check inside `validate-references.ts` short-circuited
|
|
99
|
-
// on partial context, and the analyzer reported zero issues for a
|
|
100
|
-
// manifest that explodes at runtime. Skip non-Telo no-namespace modules;
|
|
101
|
-
// they have no x-telo-ref identity to declare anyway.
|
|
102
|
-
if (!namespace && moduleName !== "Telo") return;
|
|
103
|
-
const identity = namespace ? `${namespace}/${moduleName}` : "telo";
|
|
104
|
-
this.identityMap.set(identity, moduleName);
|
|
105
|
-
this.reverseIdentityMap.set(moduleName, identity);
|
|
106
|
-
// Retroactively register AJV schemas for definitions of this module already in the registry.
|
|
107
|
-
for (const def of this.defs.values()) {
|
|
108
|
-
if (def.metadata.module === moduleName && def.schema) {
|
|
109
|
-
this.tryRegisterSchema(
|
|
110
|
-
moduleName,
|
|
111
|
-
def.metadata.name as string,
|
|
112
|
-
def.schema as Record<string, any>,
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
101
|
+
if (!namespace || moduleName === "Telo") return;
|
|
102
|
+
this.identityMap.set(`${namespace}/${moduleName}`, moduleName);
|
|
116
103
|
}
|
|
117
104
|
|
|
118
105
|
/** Registers a named `Telo.Type` resource's schema under its canonical
|
|
119
106
|
* module-scoped URI `$id` (`telo://<module>/<name>`), so a sibling schema's
|
|
120
107
|
* `$ref: "telo://Self/<name>"` (rewritten to the canonical form by
|
|
121
108
|
* `resolveSchemaTypeRefs`) resolves during AJV compilation. Mirrors the
|
|
122
|
-
* kernel type controller's `registerSchema(canonicalTypeSchemaId(...))`.
|
|
123
|
-
|
|
124
|
-
|
|
109
|
+
* kernel type controller's `registerSchema(canonicalTypeSchemaId(...))`.
|
|
110
|
+
*
|
|
111
|
+
* Returns `false` when a kind schema in the same module already owns the id —
|
|
112
|
+
* a name collision between a kind and a named type. Definitions register
|
|
113
|
+
* first, so the type is the one that would be dropped, and every
|
|
114
|
+
* `$ref: "telo://<module>/<Name>"` would then silently validate against the
|
|
115
|
+
* kind's schema instead. The caller reports it; nothing is overwritten. */
|
|
116
|
+
registerNamedTypeSchema(id: string, schema: Record<string, any>): boolean {
|
|
117
|
+
if (this.definitionSchemaIds.has(id)) return false;
|
|
118
|
+
if (this.registeredSchemaIds.has(id) || this.ajv.getSchema(id)) return true;
|
|
125
119
|
this.ajv.addSchema(schema, id);
|
|
126
120
|
this.registeredSchemaIds.add(id);
|
|
121
|
+
return true;
|
|
127
122
|
}
|
|
128
123
|
|
|
129
124
|
/** True when a schema is registered under `id` (a canonical `telo://` type id
|
|
@@ -132,14 +127,6 @@ export class DefinitionRegistry {
|
|
|
132
127
|
return this.registeredSchemaIds.has(id) || this.ajv.getSchema(id) !== undefined;
|
|
133
128
|
}
|
|
134
129
|
|
|
135
|
-
/** Computes the $id for a definition schema: "<identity>/<TypeName>".
|
|
136
|
-
* Returns undefined when the module identity is not yet registered. */
|
|
137
|
-
computeId(moduleName: string, typeName: string): string | undefined {
|
|
138
|
-
const identity = this.reverseIdentityMap.get(moduleName);
|
|
139
|
-
if (!identity) return undefined;
|
|
140
|
-
return `${identity}/${typeName}`;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
130
|
/** Validates data against a schema using this registry's AJV instance, which has all
|
|
144
131
|
* registered definition schemas loaded — enabling cross-module $ref resolution.
|
|
145
132
|
* A compile failure returns `[]` here; it is surfaced loudly (once, on the
|
|
@@ -172,37 +159,51 @@ export class DefinitionRegistry {
|
|
|
172
159
|
}
|
|
173
160
|
}
|
|
174
161
|
|
|
162
|
+
/** Registers a definition schema under the same module-scoped `telo://` id a
|
|
163
|
+
* named `Telo.Type` uses, so a kind schema and a type schema are addressable
|
|
164
|
+
* the same way and a `$ref` between them resolves at AJV compile time. One id
|
|
165
|
+
* space per module: a kind and a named type may not share a name, which
|
|
166
|
+
* `registerNamedTypeSchema` reports rather than resolving silently. */
|
|
175
167
|
private tryRegisterSchema(
|
|
176
168
|
moduleName: string,
|
|
177
169
|
typeName: string,
|
|
178
170
|
schema: Record<string, any>,
|
|
179
171
|
): void {
|
|
180
|
-
const id =
|
|
181
|
-
if (
|
|
172
|
+
const id = canonicalTypeSchemaId(moduleName, typeName);
|
|
173
|
+
if (this.registeredSchemaIds.has(id)) {
|
|
174
|
+
this.definitionSchemaIds.add(id);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
182
177
|
if (this.ajv.getSchema(id)) {
|
|
183
178
|
throw new Error(`Duplicate definition schema $id: "${id}" is already registered`);
|
|
184
179
|
}
|
|
185
180
|
this.ajv.addSchema(schema, id);
|
|
186
181
|
this.registeredSchemaIds.add(id);
|
|
182
|
+
this.definitionSchemaIds.add(id);
|
|
187
183
|
}
|
|
188
184
|
|
|
189
|
-
/** Resolves an x-telo-ref
|
|
190
|
-
*
|
|
191
|
-
*
|
|
185
|
+
/** Resolves an `x-telo-ref` constraint to a canonical registry kind key.
|
|
186
|
+
*
|
|
187
|
+
* The constraint is already canonical `<module>.<Kind>`: alias-form values
|
|
188
|
+
* (`KvStore.Store`, `Self.Store`, `Telo.Invocable`) are rewritten in the
|
|
189
|
+
* declaring module's scope by `resolveSchemaRefKinds` before registration, so
|
|
190
|
+
* no module context is needed here.
|
|
191
|
+
*
|
|
192
|
+
* The legacy `<namespace>/<module>#<Kind>` form still resolves through the
|
|
193
|
+
* identity table for module versions published before the alias form existed:
|
|
192
194
|
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
* "std/http-server#Server" → "http-server.Server"
|
|
195
|
+
* "telo#Invocable" → "Telo.Invocable"
|
|
196
|
+
* "std/http-server#Server" → "http-server.Server"
|
|
196
197
|
*
|
|
197
|
-
* Returns undefined when
|
|
198
|
+
* Returns undefined when a legacy string is malformed or its identity was
|
|
199
|
+
* never registered. */
|
|
198
200
|
resolveRef(xTeloRef: string): string | undefined {
|
|
199
201
|
const hash = xTeloRef.indexOf("#");
|
|
200
|
-
if (hash === -1
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
const moduleName = this.identityMap.get(identity);
|
|
202
|
+
if (hash === -1) return xTeloRef;
|
|
203
|
+
if (hash === xTeloRef.length - 1) return undefined;
|
|
204
|
+
const moduleName = this.identityMap.get(xTeloRef.slice(0, hash));
|
|
204
205
|
if (!moduleName) return undefined;
|
|
205
|
-
return `${moduleName}.${
|
|
206
|
+
return `${moduleName}.${xTeloRef.slice(hash + 1)}`;
|
|
206
207
|
}
|
|
207
208
|
|
|
208
209
|
resolve(kind: string): ResourceDefinition | undefined {
|
package/src/loaded-types.ts
CHANGED
|
@@ -50,18 +50,25 @@ export interface LoadedModule {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/** Resolved Telo.Import edge: where the import points and what library
|
|
53
|
-
* identity it resolves to. Carrying name
|
|
54
|
-
* `flattenForAnalyzer` can stamp `metadata.resolvedModuleName`
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
53
|
+
* identity it resolves to. Carrying the name on the edge means
|
|
54
|
+
* `flattenForAnalyzer` can stamp `metadata.resolvedModuleName` from this
|
|
55
|
+
* single source rather than re-deriving the target from manifest metadata,
|
|
56
|
+
* which would silently miss whenever a future projection forgets to stamp
|
|
57
|
+
* `metadata.source` consistently. */
|
|
58
58
|
export interface ImportEdge {
|
|
59
59
|
/** Canonical resolved URL of the target — a key into `modules`. */
|
|
60
60
|
targetSource: string;
|
|
61
|
+
/** The import's `source` exactly as authored — a registry ref, an `oci://`
|
|
62
|
+
* or `https://` ref, or a relative path. Version reconciliation keys on
|
|
63
|
+
* this (minus its version), since it names the module's location
|
|
64
|
+
* independently of what the module declares about itself. */
|
|
65
|
+
targetRef: string;
|
|
61
66
|
/** Target library's `metadata.name`, or `null` when the target had no
|
|
62
67
|
* Telo.Library doc (an error case captured in `LoadedGraph.errors`). */
|
|
63
68
|
targetModuleName: string | null;
|
|
64
|
-
/** Target library's `metadata.namespace
|
|
69
|
+
/** DEPRECATED. Target library's `metadata.namespace`, or `null` when it
|
|
70
|
+
* declares none. Feeds only the legacy `<namespace>/<module>#<Kind>` form
|
|
71
|
+
* of `x-telo-ref`; nothing else reads it. */
|
|
65
72
|
targetNamespace: string | null;
|
|
66
73
|
}
|
|
67
74
|
|
package/src/manifest-loader.ts
CHANGED
|
@@ -83,24 +83,55 @@ function compareVersions(a: ParsedVersion, b: ParsedVersion): number {
|
|
|
83
83
|
return 0;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
|
|
86
|
+
/** The location identity of an import ref: the ref with its version stripped.
|
|
87
|
+
* Two refs share an identity when they address the same module at different
|
|
88
|
+
* versions, whatever transport owns them:
|
|
89
|
+
*
|
|
90
|
+
* "std/kv-store@0.3.0" → "std/kv-store"
|
|
91
|
+
* "oci://ghcr.io/acme/s3@1.2.0" → "oci://ghcr.io/acme/s3"
|
|
92
|
+
* "https://x.com/lib/telo.yaml" → itself (a URL carries no version)
|
|
93
|
+
*
|
|
94
|
+
* Returns `null` for a relative path, which addresses one file on the
|
|
95
|
+
* publisher's disk and is therefore not a cross-import key: two local libraries
|
|
96
|
+
* that merely agree on `metadata.name` are distinct modules, and reconciling
|
|
97
|
+
* them would drop one and break its kinds. The same local file reached via two
|
|
98
|
+
* paths is already collapsed by canonical-source dedup.
|
|
99
|
+
*
|
|
100
|
+
* **What this key cannot relate.** It compares ref *spellings*, so it groups by
|
|
101
|
+
* origin exactly and nothing else. Two consequences, both accepted:
|
|
102
|
+
*
|
|
103
|
+
* - A module imported once by a registry ref and once by a relative path is two
|
|
104
|
+
* groups, so a version skew between them is not hoisted. Keying on what the
|
|
105
|
+
* module declares about itself would catch that case, but only by trusting a
|
|
106
|
+
* self-declared identity — which is what this change removes, and which
|
|
107
|
+
* cannot tell two same-named modules from different origins apart.
|
|
108
|
+
* - A bare `std/kv-store@0.4.0` and the equivalent direct
|
|
109
|
+
* `https://<registry>/std/kv-store/0.4.0/telo.yaml` are two groups. Relating
|
|
110
|
+
* them needs the configured registry base, which this pure, browser-safe
|
|
111
|
+
* function does not have. */
|
|
112
|
+
function refIdentity(ref: string): string | null {
|
|
113
|
+
const base = ref.split("#")[0];
|
|
114
|
+
if (!base || base.startsWith(".") || base.startsWith("/") || base.startsWith("file:")) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const lastSlash = base.lastIndexOf("/");
|
|
118
|
+
const at = base.lastIndexOf("@");
|
|
119
|
+
return at > lastSlash && at > 0 ? base.slice(0, at) : base;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Read a loaded module's version and raw owner text under the location
|
|
123
|
+
* identity the import edge reached it by. The identity comes from the ref, not
|
|
124
|
+
* from anything the module declares about itself — a module's own metadata
|
|
125
|
+
* cannot distinguish two same-named modules published to different origins. */
|
|
126
|
+
function moduleIdentityOf(mod: LoadedModule, identity: string): ModuleIdentity | null {
|
|
94
127
|
const doc = mod.owner.manifests.find((m) => m && isModuleKind(m.kind));
|
|
95
128
|
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;
|
|
129
|
+
const meta = doc.metadata as { name?: string; version?: string };
|
|
130
|
+
if (typeof meta?.name !== "string" || meta.name.length === 0) return null;
|
|
100
131
|
const version = typeof meta.version === "string" ? meta.version : "";
|
|
101
132
|
return {
|
|
102
133
|
source: mod.owner.source,
|
|
103
|
-
identity
|
|
134
|
+
identity,
|
|
104
135
|
version,
|
|
105
136
|
parsed: parseVersion(version),
|
|
106
137
|
text: mod.owner.text,
|
|
@@ -184,8 +215,8 @@ function hoistDiagnostic(
|
|
|
184
215
|
}
|
|
185
216
|
|
|
186
217
|
/**
|
|
187
|
-
* Reconcile a loaded import graph so each module
|
|
188
|
-
* resolves to a single version. Within a shared major the highest version wins
|
|
218
|
+
* Reconcile a loaded import graph so each module location (an import ref minus
|
|
219
|
+
* its version) resolves to a single version. Within a shared major the highest version wins
|
|
189
220
|
* (a non-lossy hoist, given Telo's additive-only pre-1.0 policy); a major
|
|
190
221
|
* mismatch is a hard conflict. Mutates `importEdges` in place — every edge that
|
|
191
222
|
* pointed at a losing source is repointed at the winner — so `flattenForAnalyzer`
|
|
@@ -199,10 +230,31 @@ export function reconcileModuleVersions(
|
|
|
199
230
|
const overrides = new Map<string, string>();
|
|
200
231
|
const diagnostics: AnalysisDiagnostic[] = [];
|
|
201
232
|
|
|
233
|
+
// Location identity per resolved module, taken from the ref that reached it.
|
|
234
|
+
// Two refs at different versions resolve to different canonical sources, so a
|
|
235
|
+
// source normally maps to exactly one identity; the entry module has no
|
|
236
|
+
// inbound edge and needs none (it is never reconciled against itself).
|
|
237
|
+
//
|
|
238
|
+
// One source CAN be reached by two spellings of the same location (a bare
|
|
239
|
+
// registry ref and the direct URL it resolves to). Both name the same module
|
|
240
|
+
// at the same version, so either identity groups it correctly — but the choice
|
|
241
|
+
// must not depend on edge iteration order, or the same graph could reconcile
|
|
242
|
+
// differently across runs. First edge wins.
|
|
243
|
+
const identityBySource = new Map<string, string>();
|
|
244
|
+
for (const aliasMap of importEdges.values()) {
|
|
245
|
+
for (const edge of aliasMap.values()) {
|
|
246
|
+
if (identityBySource.has(edge.targetSource)) continue;
|
|
247
|
+
const identity = refIdentity(edge.targetRef);
|
|
248
|
+
if (identity) identityBySource.set(edge.targetSource, identity);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
202
252
|
const groups = new Map<string, ModuleIdentity[]>();
|
|
203
253
|
const infoBySource = new Map<string, ModuleIdentity>();
|
|
204
|
-
for (const mod of modules
|
|
205
|
-
const
|
|
254
|
+
for (const [source, mod] of modules) {
|
|
255
|
+
const identity = identityBySource.get(source);
|
|
256
|
+
if (!identity) continue;
|
|
257
|
+
const info = moduleIdentityOf(mod, identity);
|
|
206
258
|
if (!info) continue;
|
|
207
259
|
infoBySource.set(info.source, info);
|
|
208
260
|
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
|