@telorun/analyzer 0.26.0 → 0.28.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.
@@ -0,0 +1,10 @@
1
+ import { HttpSource } from "./http-source.js";
2
+ import { RegistrySource } from "./registry-source.js";
3
+ /** The browser-safe built-in sources, in resolution order: HTTP fetch then
4
+ * registry. Node-specific sources (local filesystem) are supplied by the
5
+ * consuming package and passed alongside these into the `Loader` constructor.
6
+ * Callers that only want a subset (e.g. the editor, which brings its own
7
+ * registry adapters) construct the individual sources directly. */
8
+ export function defaultSources(registryUrl) {
9
+ return [new HttpSource(), new RegistrySource(registryUrl)];
10
+ }
package/dist/types.d.ts CHANGED
@@ -68,14 +68,6 @@ export interface LoadOptions {
68
68
  desugarImports?: boolean;
69
69
  }
70
70
  export interface LoaderInitOptions {
71
- /** Sources inserted with highest priority before built-ins. */
72
- extraSources?: ManifestSource[];
73
- /** Include built-in HttpSource. Defaults to true. */
74
- includeHttpSource?: boolean;
75
- /** Include built-in RegistrySource. Defaults to true. */
76
- includeRegistrySource?: boolean;
77
- /** Base URL used by built-in RegistrySource when enabled. */
78
- registryUrl?: string;
79
71
  /** Handlers for CEL stdlib functions (e.g. `sha256`). Analyzer-only callers may
80
72
  * omit this and get throwing stubs; runtime callers (kernel) must supply real impls. */
81
73
  celHandlers?: import("./cel-environment.js").CelHandlers;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;qHACqH;AACrH,eAAO,MAAM,kBAAkB;;;;;CAKrB,CAAC;AACX,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,OAAO,kBAAkB,CAAC,CAAC;AAE9F,gFAAgF;AAChF,eAAO,MAAM,yBAAyB,cAAc,CAAC;AAErD,MAAM,WAAW,QAAQ;IACvB,0BAA0B;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,+BAA+B;IAC/B,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,KAAK;IACpB,KAAK,EAAE,QAAQ,CAAC;IAChB,GAAG,EAAE,QAAQ,CAAC;CACf;AAED;;oDAEoD;AACpD,MAAM,MAAM,aAAa,GAAG,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAE/C;6EAC6E;AAC7E,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,QAAQ,CAAC,EAAE,kBAAkB,CAAC;IAC9B,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,2BAA2B;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;IAC/B,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC7D,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;IAExD;;qEAEiE;IACjE,UAAU,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAEjE;;qEAEiE;IACjE,cAAc,CAAC,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CAC1D;AAED,MAAM,WAAW,WAAW;IAC1B;;;+EAG2E;IAC3E,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;;;mEAO+D;IAC/D,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,iBAAiB;IAChC,+DAA+D;IAC/D,YAAY,CAAC,EAAE,cAAc,EAAE,CAAC;IAChC,qDAAqD;IACrD,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,yDAAyD;IACzD,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,6DAA6D;IAC7D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;6FACyF;IACzF,WAAW,CAAC,EAAE,OAAO,sBAAsB,EAAE,WAAW,CAAC;CAC1D;AAED,MAAM,WAAW,eAAe;IAC9B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;;;;;;;;sDAUkD;IAClD,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED;;;;;gEAKgE;AAChE,MAAM,WAAW,eAAe;IAC9B,OAAO,CAAC,EAAE,OAAO,qBAAqB,EAAE,aAAa,CAAC;IACtD,WAAW,CAAC,EAAE,OAAO,0BAA0B,EAAE,kBAAkB,CAAC;IACpE;;;;+EAI2E;IAC3E,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,qBAAqB,EAAE,aAAa,CAAC,CAAC;CAC5E"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;qHACqH;AACrH,eAAO,MAAM,kBAAkB;;;;;CAKrB,CAAC;AACX,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,OAAO,kBAAkB,CAAC,CAAC;AAE9F,gFAAgF;AAChF,eAAO,MAAM,yBAAyB,cAAc,CAAC;AAErD,MAAM,WAAW,QAAQ;IACvB,0BAA0B;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,+BAA+B;IAC/B,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,KAAK;IACpB,KAAK,EAAE,QAAQ,CAAC;IAChB,GAAG,EAAE,QAAQ,CAAC;CACf;AAED;;oDAEoD;AACpD,MAAM,MAAM,aAAa,GAAG,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAE/C;6EAC6E;AAC7E,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,QAAQ,CAAC,EAAE,kBAAkB,CAAC;IAC9B,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,2BAA2B;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;IAC/B,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC7D,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;IAExD;;qEAEiE;IACjE,UAAU,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAEjE;;qEAEiE;IACjE,cAAc,CAAC,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CAC1D;AAED,MAAM,WAAW,WAAW;IAC1B;;;+EAG2E;IAC3E,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;;;mEAO+D;IAC/D,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,iBAAiB;IAChC;6FACyF;IACzF,WAAW,CAAC,EAAE,OAAO,sBAAsB,EAAE,WAAW,CAAC;CAC1D;AAED,MAAM,WAAW,eAAe;IAC9B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;;;;;;;;sDAUkD;IAClD,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED;;;;;gEAKgE;AAChE,MAAM,WAAW,eAAe;IAC9B,OAAO,CAAC,EAAE,OAAO,qBAAqB,EAAE,aAAa,CAAC;IACtD,WAAW,CAAC,EAAE,OAAO,0BAA0B,EAAE,kBAAkB,CAAC;IACpE;;;;+EAI2E;IAC3E,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,qBAAqB,EAAE,aAAa,CAAC,CAAC;CAC5E"}
@@ -18,6 +18,14 @@ import { type AnalysisDiagnostic } from "./types.js";
18
18
  * kind is registered but not a `Telo.Invocable`.
19
19
  * - PROVIDER_MISSING_IMPLEMENTATION: definition with `capability: Telo.Provider`
20
20
  * declares neither `controllers:` (TS-backed) nor `provide:` (template-backed).
21
+ * - MOUNT_ON_NON_MOUNT: `mount:` declared on a definition whose `capability` is
22
+ * not `Telo.Mount`.
23
+ * - MOUNT_DISPATCHER_CONFLICT: `mount:` co-exists with another dispatch
24
+ * entry-point (`invoke:` / `run:` / `provide:`).
25
+ * - MOUNT_TARGET_UNKNOWN: `mount.name` does not resolve to an entry in
26
+ * `resources:`.
27
+ * - MOUNT_TARGET_NOT_MOUNTABLE: `mount.name` resolves to a resource whose kind
28
+ * is registered but not a `Telo.Mount`.
21
29
  */
22
30
  export declare function validateProviderCoherence(manifests: ResourceManifest[], registry: DefinitionRegistry, aliases: AliasResolver): AnalysisDiagnostic[];
23
31
  //# sourceMappingURL=validate-provider-coherence.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"validate-provider-coherence.d.ts","sourceRoot":"","sources":["../src/validate-provider-coherence.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AACnE,OAAO,EAAsB,KAAK,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAIzE;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,yBAAyB,CACvC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,kBAAkB,EAC5B,OAAO,EAAE,aAAa,GACrB,kBAAkB,EAAE,CAyItB"}
1
+ {"version":3,"file":"validate-provider-coherence.d.ts","sourceRoot":"","sources":["../src/validate-provider-coherence.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AACnE,OAAO,EAAsB,KAAK,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAIzE;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,yBAAyB,CACvC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,kBAAkB,EAC5B,OAAO,EAAE,aAAa,GACrB,kBAAkB,EAAE,CAsNtB"}
@@ -16,6 +16,14 @@ const SOURCE = "telo-analyzer";
16
16
  * kind is registered but not a `Telo.Invocable`.
17
17
  * - PROVIDER_MISSING_IMPLEMENTATION: definition with `capability: Telo.Provider`
18
18
  * declares neither `controllers:` (TS-backed) nor `provide:` (template-backed).
19
+ * - MOUNT_ON_NON_MOUNT: `mount:` declared on a definition whose `capability` is
20
+ * not `Telo.Mount`.
21
+ * - MOUNT_DISPATCHER_CONFLICT: `mount:` co-exists with another dispatch
22
+ * entry-point (`invoke:` / `run:` / `provide:`).
23
+ * - MOUNT_TARGET_UNKNOWN: `mount.name` does not resolve to an entry in
24
+ * `resources:`.
25
+ * - MOUNT_TARGET_NOT_MOUNTABLE: `mount.name` resolves to a resource whose kind
26
+ * is registered but not a `Telo.Mount`.
19
27
  */
20
28
  export function validateProviderCoherence(manifests, registry, aliases) {
21
29
  const diagnostics = [];
@@ -45,11 +53,13 @@ export function validateProviderCoherence(manifests, registry, aliases) {
45
53
  const provide = md.provide;
46
54
  const invoke = md.invoke;
47
55
  const run = md.run;
56
+ const mount = md.mount;
48
57
  const controllers = md.controllers;
49
58
  const resources = md.resources;
50
59
  const hasProvide = provide !== undefined && provide !== null;
51
60
  const hasInvoke = invoke !== undefined && invoke !== null;
52
61
  const hasRun = run !== undefined && run !== null;
62
+ const hasMount = mount !== undefined && mount !== null;
53
63
  const hasControllers = Array.isArray(controllers) && controllers.length > 0;
54
64
  if (hasProvide && capability !== "Telo.Provider") {
55
65
  diagnostics.push({
@@ -133,6 +143,77 @@ export function validateProviderCoherence(manifests, registry, aliases) {
133
143
  }
134
144
  }
135
145
  }
146
+ if (hasMount && capability !== "Telo.Mount") {
147
+ diagnostics.push({
148
+ severity: DiagnosticSeverity.Error,
149
+ code: "MOUNT_ON_NON_MOUNT",
150
+ source: SOURCE,
151
+ message: `${label}: 'mount:' is only valid on definitions with 'capability: Telo.Mount' ` +
152
+ `(found '${capability ?? "<unset>"}'). Use 'invoke:' / 'run:' / 'provide:' for other capabilities.`,
153
+ data: { resource, filePath, path: "mount" },
154
+ });
155
+ }
156
+ if (hasMount && (hasInvoke || hasRun || hasProvide)) {
157
+ const conflict = hasInvoke ? "invoke" : hasRun ? "run" : "provide";
158
+ diagnostics.push({
159
+ severity: DiagnosticSeverity.Error,
160
+ code: "MOUNT_DISPATCHER_CONFLICT",
161
+ source: SOURCE,
162
+ message: `${label}: 'mount:' cannot co-exist with '${conflict}:'. ` +
163
+ `A definition declares exactly one dispatch entry-point.`,
164
+ data: { resource, filePath, path: "mount" },
165
+ });
166
+ }
167
+ if (hasMount) {
168
+ // Resolve the target's name from either form: the bare string (the
169
+ // primary, documented form — `mount: api`) or the object's `name`. A CEL
170
+ // target (`${{ … }}`) can only be checked at runtime, so skip those.
171
+ let mountedName;
172
+ if (typeof mount === "string") {
173
+ if (!mount.includes("${{"))
174
+ mountedName = mount;
175
+ }
176
+ else if (typeof mount === "object" && !Array.isArray(mount)) {
177
+ const mountObj = mount;
178
+ if (typeof mountObj.name === "string" && !mountObj.name.includes("${{")) {
179
+ mountedName = mountObj.name;
180
+ }
181
+ }
182
+ const mountPath = typeof mount === "string" ? "mount" : "mount.name";
183
+ if (mountedName && Array.isArray(resources)) {
184
+ const match = resources.find((r) => {
185
+ const meta = r?.metadata;
186
+ return typeof meta?.name === "string" && meta.name === mountedName;
187
+ });
188
+ if (!match) {
189
+ diagnostics.push({
190
+ severity: DiagnosticSeverity.Error,
191
+ code: "MOUNT_TARGET_UNKNOWN",
192
+ source: SOURCE,
193
+ message: `${label}: '${mountPath}: ${mountedName}' does not match any entry's ` +
194
+ `metadata.name in 'resources:'.`,
195
+ data: { resource, filePath, path: mountPath },
196
+ });
197
+ }
198
+ else if (typeof match.kind === "string") {
199
+ const resolvedKind = aliases.resolveKind(match.kind) ?? match.kind;
200
+ const targetDef = registry.resolve(resolvedKind) ?? registry.resolve(match.kind);
201
+ if (targetDef && targetDef.kind === "Telo.Definition") {
202
+ const targetCap = targetDef.capability;
203
+ if (typeof targetCap === "string" && targetCap !== "Telo.Mount") {
204
+ diagnostics.push({
205
+ severity: DiagnosticSeverity.Error,
206
+ code: "MOUNT_TARGET_NOT_MOUNTABLE",
207
+ source: SOURCE,
208
+ message: `${label}: '${mountPath}: ${mountedName}' resolves to a ${match.kind} ` +
209
+ `(capability '${targetCap}'); 'mount:' requires a Telo.Mount target.`,
210
+ data: { resource, filePath, path: mountPath },
211
+ });
212
+ }
213
+ }
214
+ }
215
+ }
216
+ }
136
217
  if (capability === "Telo.Provider" && !hasControllers && !hasProvide) {
137
218
  diagnostics.push({
138
219
  severity: DiagnosticSeverity.Error,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -48,7 +48,7 @@
48
48
  "@types/node": "^20.0.0",
49
49
  "typescript": "^5.0.0",
50
50
  "vitest": "^2.1.8",
51
- "@telorun/sdk": "0.34.0"
51
+ "@telorun/sdk": "0.36.0"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@telorun/sdk": "*"
package/src/builtins.ts CHANGED
@@ -61,12 +61,28 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
61
61
  items: {
62
62
  type: "object",
63
63
  additionalProperties: true,
64
+ // Resource bodies are `self`-only for config: per-call `inputs` is
65
+ // NOT in scope here. Each entry is a persistent child created once at
66
+ // init() and reused, so its config cannot depend on call-time data —
67
+ // that flows through the top-level `inputs:` sibling into the dispatch
68
+ // target's invoke().
69
+ //
70
+ // The exception is CEL the child's OWN controller evaluates later
71
+ // against a runtime context it owns (e.g. an Http.Api evaluating route
72
+ // CEL per request). Those `request` / `result` / `steps` / `error`
73
+ // variables are deferred — the template controller preserves them
74
+ // untouched (see resource-template-controller.ts) — so they are
75
+ // exposed here permissively. Their deep shape is the child kind's
76
+ // concern, not the template's, so they type as open values.
64
77
  "x-telo-context": {
65
78
  type: "object",
66
79
  additionalProperties: false,
67
80
  properties: {
68
81
  self: { "x-telo-context-from-root": "schema" },
69
- inputs: { "x-telo-context-from-root": "inputType" },
82
+ request: {},
83
+ result: {},
84
+ steps: {},
85
+ error: {},
70
86
  },
71
87
  },
72
88
  },
@@ -129,6 +145,41 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
129
145
  },
130
146
  },
131
147
  },
148
+ // Mount dispatch: names the `resources:` entry (a Telo.Mount, e.g. an
149
+ // Http.Api) whose `register()` this definition delegates to. Same
150
+ // string / { kind, name } grammar as `invoke:`. The named child stays
151
+ // persistent so the produced mount's routes can `!ref` its siblings.
152
+ mount: {
153
+ oneOf: [
154
+ {
155
+ type: "string",
156
+ "x-telo-context": {
157
+ type: "object",
158
+ additionalProperties: false,
159
+ properties: {
160
+ self: { "x-telo-context-from-root": "schema" },
161
+ },
162
+ },
163
+ },
164
+ {
165
+ type: "object",
166
+ additionalProperties: true,
167
+ properties: {
168
+ kind: { type: "string" },
169
+ name: {
170
+ type: "string",
171
+ "x-telo-context": {
172
+ type: "object",
173
+ additionalProperties: false,
174
+ properties: {
175
+ self: { "x-telo-context-from-root": "schema" },
176
+ },
177
+ },
178
+ },
179
+ },
180
+ },
181
+ ],
182
+ },
132
183
  inputs: {
133
184
  type: "object",
134
185
  additionalProperties: true,
package/src/index.ts CHANGED
@@ -39,6 +39,8 @@ export { parseLoadedFile } from "./parse-loaded-file.js";
39
39
  export type { ParseOptions } from "./parse-loaded-file.js";
40
40
  export { desugarLoadedFile, inlineImportManifests } from "./inline-imports.js";
41
41
  export type { SyntheticImport } from "./inline-imports.js";
42
+ export { reconcileModuleVersions } from "./reconcile-module-versions.js";
43
+ export type { VersionReconciliation } from "./reconcile-module-versions.js";
42
44
  export { residualEntrySchema, residualEntrySchemaMap } from "./residual-schema.js";
43
45
  export {
44
46
  buildDocumentPositions,
@@ -49,6 +51,7 @@ export {
49
51
  export type { DocumentPosition } from "./position-metadata.js";
50
52
  export { HttpSource } from "./sources/http-source.js";
51
53
  export { RegistrySource } from "./sources/registry-source.js";
54
+ export { defaultSources } from "./sources/default-sources.js";
52
55
  export { withSyntheticPositions } from "./with-synthetic-positions.js";
53
56
  export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity } from "./types.js";
54
57
  export type {
@@ -1,7 +1,7 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
2
  import type { Document } from "yaml";
3
3
  import type { DocumentPosition } from "./position-metadata.js";
4
- import type { Range } from "./types.js";
4
+ import type { AnalysisDiagnostic, Range } from "./types.js";
5
5
 
6
6
  /** One physical file's parsed result. Returned for the owner manifest, for
7
7
  * each `include:` partial, and for each external import target.
@@ -70,8 +70,21 @@ export interface LoadedGraph {
70
70
  * its partials. */
71
71
  modules: Map<string, LoadedModule>;
72
72
  /** Per-Telo.Import resolution. Keyed by the resolved URL of the file the
73
- * Telo.Import was declared in, then by the import's PascalCase alias. */
73
+ * Telo.Import was declared in, then by the import's PascalCase alias.
74
+ * Version reconciliation repoints losing edges at their winner here, so a
75
+ * consumer walking these edges (`flattenForAnalyzer`) sees one version per
76
+ * module identity. */
74
77
  importEdges: Map<string, Map<string, ImportEdge>>;
78
+ /** Version-reconciliation redirects: a losing module's canonical source URL →
79
+ * the winning version's canonical source URL. The runtime consults this when
80
+ * it independently re-resolves an import (the analyzer already sees repointed
81
+ * `importEdges`). Empty when no module identity appeared at two sources. */
82
+ overrides: Map<string, string>;
83
+ /** Diagnostics produced while reconciling module versions — one per import
84
+ * edge redirected to a different version (warning for a same-major hoist,
85
+ * error for a major mismatch). Surfaced alongside `analyze()` diagnostics by
86
+ * every consumer (CLI, editor, VS Code). */
87
+ versionDiagnostics: AnalysisDiagnostic[];
75
88
  /** Surface-level errors that did not abort the graph load (e.g. an import
76
89
  * whose target failed to fetch). */
77
90
  errors: GraphLoadError[];
@@ -1,7 +1,5 @@
1
1
  import type { Environment } from "@marcbachmann/cel-js";
2
2
  import type { ResourceManifest } from "@telorun/sdk";
3
- import { HttpSource } from "./sources/http-source.js";
4
- import { RegistrySource } from "./sources/registry-source.js";
5
3
  import { buildCelEnvironment } from "./cel-environment.js";
6
4
  import type {
7
5
  GraphLoadError,
@@ -13,6 +11,7 @@ import type {
13
11
  import { desugarLoadedFile } from "./inline-imports.js";
14
12
  import { isModuleKind } from "./module-kinds.js";
15
13
  import { parseLoadedFile } from "./parse-loaded-file.js";
14
+ import { reconcileModuleVersions } from "./reconcile-module-versions.js";
16
15
  import {
17
16
  DEFAULT_MANIFEST_FILENAME,
18
17
  type LoadOptions,
@@ -53,22 +52,13 @@ export class Loader {
53
52
  protected sources: ManifestSource[];
54
53
  private readonly celEnv: Environment;
55
54
 
56
- constructor(extraSourcesOrOptions: ManifestSource[] | LoaderInitOptions = []) {
57
- const options: LoaderInitOptions = Array.isArray(extraSourcesOrOptions)
58
- ? { extraSources: extraSourcesOrOptions }
59
- : extraSourcesOrOptions;
60
-
61
- const includeHttpSource = options.includeHttpSource ?? true;
62
- const includeRegistrySource = options.includeRegistrySource ?? true;
63
-
64
- this.sources = [];
65
- if (includeHttpSource) this.sources.push(new HttpSource());
66
- if (includeRegistrySource) this.sources.push(new RegistrySource(options.registryUrl));
67
-
68
- if (options.extraSources?.length) {
69
- this.sources.unshift(...options.extraSources);
70
- }
71
-
55
+ /** Sources are resolved in order — the first whose `supports(url)` matches
56
+ * wins. The caller (composition root) decides which concrete sources exist
57
+ * and supplies them; `defaultSources()` bundles the browser-safe built-ins
58
+ * (HTTP + registry) for the common case. `register()` prepends a source at
59
+ * runtime. */
60
+ constructor(sources: ManifestSource[] = [], options: LoaderInitOptions = {}) {
61
+ this.sources = [...sources];
72
62
  this.celEnv = buildCelEnvironment(options.celHandlers);
73
63
  }
74
64
 
@@ -307,7 +297,20 @@ export class Loader {
307
297
  }
308
298
  }
309
299
 
310
- return { rootSource, entry, modules, importEdges, errors };
300
+ // Collapse multiple versions of the same module identity onto one version
301
+ // before any consumer walks the edges: repoints losing `importEdges` in
302
+ // place and yields the runtime override map + hoist/conflict diagnostics.
303
+ const { overrides, diagnostics } = reconcileModuleVersions(modules, importEdges);
304
+
305
+ return {
306
+ rootSource,
307
+ entry,
308
+ modules,
309
+ importEdges,
310
+ overrides,
311
+ versionDiagnostics: diagnostics,
312
+ errors,
313
+ };
311
314
  }
312
315
 
313
316
  /** Resolve an `import` URL against the file it appears in. Relative /
@@ -0,0 +1,253 @@
1
+ import type { ImportEdge, LoadedModule } from "./loaded-types.js";
2
+ import { isModuleKind } from "./module-kinds.js";
3
+ import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
4
+
5
+ const SOURCE = "telo-analyzer";
6
+
7
+ /** Outcome of reconciling a module name that appears at more than one resolved
8
+ * source in a single import graph. The `overrides` map redirects each losing
9
+ * canonical URL to the winner's canonical URL — consulted by the runtime when
10
+ * it independently re-resolves an import (the analyzer side is handled by
11
+ * repointing `importEdges` in place). */
12
+ export interface VersionReconciliation {
13
+ /** Loser canonical source URL → winner canonical source URL. */
14
+ overrides: Map<string, string>;
15
+ /** One diagnostic per import edge that pointed at a non-winner: a warning for
16
+ * a same-major hoist, an error for an incompatible major mismatch. */
17
+ diagnostics: AnalysisDiagnostic[];
18
+ }
19
+
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
+ interface ModuleIdentity {
29
+ source: string;
30
+ identity: string;
31
+ version: string;
32
+ parsed: ParsedVersion | null;
33
+ text: string;
34
+ }
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
+ }
82
+ }
83
+ return 0;
84
+ }
85
+
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 {
94
+ const doc = mod.owner.manifests.find((m) => m && isModuleKind(m.kind));
95
+ 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;
100
+ const version = typeof meta.version === "string" ? meta.version : "";
101
+ return {
102
+ source: mod.owner.source,
103
+ identity: `${meta.namespace}/${name}`,
104
+ version,
105
+ parsed: parseVersion(version),
106
+ text: mod.owner.text,
107
+ };
108
+ }
109
+
110
+ interface GroupResolution {
111
+ winner: ModuleIdentity;
112
+ /** True when members disagree on major version (or a version is unparseable). */
113
+ conflict: boolean;
114
+ }
115
+
116
+ /** Pick the winning member of a same-identity group and classify it. The winner
117
+ * is the highest version (deterministic tiebreak on source URL for equal
118
+ * versions / same-version-different-source). A major disagreement — or any
119
+ * unparseable version — marks the group a conflict; we still pick a winner so
120
+ * the rest of analysis proceeds against a single version instead of cascading
121
+ * duplicate-kind errors. */
122
+ function resolveGroup(members: ModuleIdentity[]): GroupResolution {
123
+ const majors = new Set<number | null>();
124
+ for (const m of members) majors.add(m.parsed ? m.parsed.major : null);
125
+ const conflict = majors.has(null) || majors.size > 1;
126
+
127
+ const winner = members.reduce((best, cur) => {
128
+ if (!cur.parsed) return best;
129
+ if (!best.parsed) return cur;
130
+ const cmp = compareVersions(cur.parsed, best.parsed);
131
+ if (cmp > 0) return cur;
132
+ if (cmp === 0 && cur.source < best.source) return cur;
133
+ return best;
134
+ }, members[0]);
135
+
136
+ return { winner, conflict };
137
+ }
138
+
139
+ /** The diagnostic for a redirected edge, or `null` when the redirect is a
140
+ * silent dedupe (the same version resolved from two sources with identical
141
+ * content — no decision was made, so nothing to report). */
142
+ function hoistDiagnostic(
143
+ identity: string,
144
+ importerSource: string,
145
+ alias: string,
146
+ loser: ModuleIdentity,
147
+ winner: ModuleIdentity,
148
+ conflict: boolean,
149
+ ): AnalysisDiagnostic | null {
150
+ const data = { filePath: importerSource, path: `imports.${alias}` };
151
+ if (conflict) {
152
+ return {
153
+ severity: DiagnosticSeverity.Error,
154
+ code: "MODULE_VERSION_CONFLICT",
155
+ source: SOURCE,
156
+ message:
157
+ `Module '${identity}' is imported at incompatible major versions: ` +
158
+ `${loser.version || "<unknown>"} here and ${winner.version} elsewhere in the same graph. ` +
159
+ `Major versions can carry breaking changes and cannot be reconciled automatically — ` +
160
+ `align every importer on one major.`,
161
+ data,
162
+ };
163
+ }
164
+ if (loser.version === winner.version) {
165
+ // Same version, two sources. Identical content is a no-op dedupe; differing
166
+ // content means one is masquerading as the other (e.g. a local checkout vs
167
+ // the published version) — worth surfacing.
168
+ if (loser.text === winner.text) return null;
169
+ return {
170
+ severity: DiagnosticSeverity.Warning,
171
+ code: "MODULE_VERSION_HOISTED",
172
+ source: SOURCE,
173
+ message:
174
+ `Module '${identity}@${winner.version}' is imported from two sources whose contents ` +
175
+ `differ ('${loser.source}' and '${winner.source}'). Using '${winner.source}' for every ` +
176
+ `importer — pin a single source to remove the ambiguity.`,
177
+ data,
178
+ };
179
+ }
180
+ return {
181
+ severity: DiagnosticSeverity.Warning,
182
+ code: "MODULE_VERSION_HOISTED",
183
+ source: SOURCE,
184
+ message:
185
+ `Module '${identity}@${loser.version || "<unknown>"}' was hoisted to '${winner.version}' ` +
186
+ `because the same module is imported at the higher version elsewhere in the graph. ` +
187
+ `Pre-1.0 versions are additive, so the higher version is used for every importer.`,
188
+ data,
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Reconcile a loaded import graph so each module identity (`namespace/name`)
194
+ * resolves to a single version. Within a shared major the highest version wins
195
+ * (a non-lossy hoist, given Telo's additive-only pre-1.0 policy); a major
196
+ * mismatch is a hard conflict. Mutates `importEdges` in place — every edge that
197
+ * pointed at a losing source is repointed at the winner — so `flattenForAnalyzer`
198
+ * walks a deduplicated graph and the runtime collision (two definitions of the
199
+ * same kind) cannot occur. Pure and browser-safe: no I/O, no Node built-ins.
200
+ */
201
+ export function reconcileModuleVersions(
202
+ modules: Map<string, LoadedModule>,
203
+ importEdges: Map<string, Map<string, ImportEdge>>,
204
+ ): VersionReconciliation {
205
+ const overrides = new Map<string, string>();
206
+ const diagnostics: AnalysisDiagnostic[] = [];
207
+
208
+ const groups = new Map<string, ModuleIdentity[]>();
209
+ const infoBySource = new Map<string, ModuleIdentity>();
210
+ for (const mod of modules.values()) {
211
+ const info = moduleIdentityOf(mod);
212
+ if (!info) continue;
213
+ infoBySource.set(info.source, info);
214
+ const list = groups.get(info.identity);
215
+ if (list) list.push(info);
216
+ else groups.set(info.identity, [info]);
217
+ }
218
+
219
+ const conflictByIdentity = new Map<string, boolean>();
220
+ for (const [identity, members] of groups) {
221
+ if (members.length < 2) continue;
222
+ const { winner, conflict } = resolveGroup(members);
223
+ conflictByIdentity.set(identity, conflict);
224
+ for (const member of members) {
225
+ if (member.source !== winner.source) overrides.set(member.source, winner.source);
226
+ }
227
+ }
228
+
229
+ if (overrides.size === 0) return { overrides, diagnostics };
230
+
231
+ for (const [importerSource, aliasMap] of importEdges) {
232
+ for (const [alias, edge] of aliasMap) {
233
+ const winnerSource = overrides.get(edge.targetSource);
234
+ if (!winnerSource) continue;
235
+ const loser = infoBySource.get(edge.targetSource);
236
+ const winner = infoBySource.get(winnerSource);
237
+ if (loser && winner) {
238
+ const diag = hoistDiagnostic(
239
+ loser.identity,
240
+ importerSource,
241
+ alias,
242
+ loser,
243
+ winner,
244
+ conflictByIdentity.get(loser.identity) ?? false,
245
+ );
246
+ if (diag) diagnostics.push(diag);
247
+ }
248
+ edge.targetSource = winnerSource;
249
+ }
250
+ }
251
+
252
+ return { overrides, diagnostics };
253
+ }
@@ -0,0 +1,12 @@
1
+ import type { ManifestSource } from "../types.js";
2
+ import { HttpSource } from "./http-source.js";
3
+ import { RegistrySource } from "./registry-source.js";
4
+
5
+ /** The browser-safe built-in sources, in resolution order: HTTP fetch then
6
+ * registry. Node-specific sources (local filesystem) are supplied by the
7
+ * consuming package and passed alongside these into the `Loader` constructor.
8
+ * Callers that only want a subset (e.g. the editor, which brings its own
9
+ * registry adapters) construct the individual sources directly. */
10
+ export function defaultSources(registryUrl?: string): ManifestSource[] {
11
+ return [new HttpSource(), new RegistrySource(registryUrl)];
12
+ }
package/src/types.ts CHANGED
@@ -75,14 +75,6 @@ export interface LoadOptions {
75
75
  }
76
76
 
77
77
  export interface LoaderInitOptions {
78
- /** Sources inserted with highest priority before built-ins. */
79
- extraSources?: ManifestSource[];
80
- /** Include built-in HttpSource. Defaults to true. */
81
- includeHttpSource?: boolean;
82
- /** Include built-in RegistrySource. Defaults to true. */
83
- includeRegistrySource?: boolean;
84
- /** Base URL used by built-in RegistrySource when enabled. */
85
- registryUrl?: string;
86
78
  /** Handlers for CEL stdlib functions (e.g. `sha256`). Analyzer-only callers may
87
79
  * omit this and get throwing stubs; runtime callers (kernel) must supply real impls. */
88
80
  celHandlers?: import("./cel-environment.js").CelHandlers;