@telorun/analyzer 0.42.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.
Files changed (44) hide show
  1. package/dist/analysis-registry.d.ts +4 -4
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +3 -3
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/analyzer.js +92 -3
  6. package/dist/builtins.js +7 -7
  7. package/dist/definition-registry.d.ts +49 -22
  8. package/dist/definition-registry.d.ts.map +1 -1
  9. package/dist/definition-registry.js +67 -61
  10. package/dist/loaded-types.d.ts +13 -6
  11. package/dist/loaded-types.d.ts.map +1 -1
  12. package/dist/manifest-loader.d.ts.map +1 -1
  13. package/dist/manifest-loader.js +1 -0
  14. package/dist/reconcile-module-versions.d.ts +2 -2
  15. package/dist/reconcile-module-versions.d.ts.map +1 -1
  16. package/dist/reconcile-module-versions.js +69 -17
  17. package/dist/reference-field-map.d.ts +2 -1
  18. package/dist/reference-field-map.d.ts.map +1 -1
  19. package/dist/reference-field-map.js +1 -1
  20. package/dist/resolve-schema-ref-kinds.d.ts +62 -0
  21. package/dist/resolve-schema-ref-kinds.d.ts.map +1 -0
  22. package/dist/resolve-schema-ref-kinds.js +81 -0
  23. package/dist/sources/manifest-cache.d.ts +14 -4
  24. package/dist/sources/manifest-cache.d.ts.map +1 -1
  25. package/dist/sources/manifest-cache.js +15 -6
  26. package/dist/validate-nested-inline.d.ts +3 -1
  27. package/dist/validate-nested-inline.d.ts.map +1 -1
  28. package/dist/validate-nested-inline.js +14 -2
  29. package/dist/validate-value-schema.d.ts +13 -0
  30. package/dist/validate-value-schema.d.ts.map +1 -0
  31. package/dist/validate-value-schema.js +114 -0
  32. package/package.json +2 -2
  33. package/src/analysis-registry.ts +4 -4
  34. package/src/analyzer.ts +97 -3
  35. package/src/builtins.ts +7 -7
  36. package/src/definition-registry.ts +67 -66
  37. package/src/loaded-types.ts +13 -6
  38. package/src/manifest-loader.ts +1 -0
  39. package/src/reconcile-module-versions.ts +69 -17
  40. package/src/reference-field-map.ts +3 -2
  41. package/src/resolve-schema-ref-kinds.ts +118 -0
  42. package/src/sources/manifest-cache.ts +26 -8
  43. package/src/validate-nested-inline.ts +13 -1
  44. package/src/validate-value-schema.ts +126 -0
@@ -17,7 +17,7 @@ export interface RefFieldInfo {
17
17
  path: string;
18
18
  /** True when the path traverses at least one array. */
19
19
  isArray: boolean;
20
- /** Accepted `x-telo-ref` constraint strings (e.g. `telo#Runnable`). */
20
+ /** Accepted `x-telo-ref` constraint strings, canonicalized (e.g. `Telo.Runnable`). */
21
21
  refs: string[];
22
22
  /** Distinct capabilities the slot may target (`Telo.Runnable`,
23
23
  * `Telo.Service`, `Telo.Provider`, …) — one per resolvable constraint. The
@@ -119,9 +119,9 @@ export class AnalysisRegistry {
119
119
 
120
120
  /** Base capability an `x-telo-ref` constraint targets. A definition's declared
121
121
  * `capability` is always one of the base capabilities, so it wins — this
122
- * resolves user-defined abstracts (e.g. `std/ai#Model`, declared
122
+ * resolves user-defined abstracts (e.g. `ai.Model`, declared
123
123
  * `capability: Telo.Invocable`) to the capability instances satisfy, not the
124
- * abstract kind. Builtin abstracts (`telo#Runnable`) carry no `capability`
124
+ * abstract kind. Builtin abstracts (`Telo.Runnable`) carry no `capability`
125
125
  * field — there the kind itself *is* the capability. Undefined when
126
126
  * unresolvable. */
127
127
  capabilityForRef(xTeloRef: string): string | undefined {
@@ -306,7 +306,7 @@ export class AnalysisRegistry {
306
306
  }
307
307
 
308
308
  /** Returns every user-facing (alias-form) kind that satisfies the given
309
- * `x-telo-ref` constraint string (e.g. `"telo#Invocable"`, `"std/sql#Connection"`).
309
+ * `x-telo-ref` constraint string, canonicalized (e.g. `"Telo.Invocable"`, `"sql.Connection"`).
310
310
  * Resolution mirrors `validateReferences.checkKind`: abstract targets expand to
311
311
  * the set of definitions extending them; concrete targets yield just themselves.
312
312
  * Returns `undefined` when the ref can't be resolved (e.g. unregistered identity),
package/src/analyzer.ts CHANGED
@@ -20,6 +20,7 @@ import { isModuleKind } from "./module-kinds.js";
20
20
  import { normalizeInlineResources } from "./normalize-inline-resources.js";
21
21
  import { REF_VALIDATION_SKIP_KINDS } from "./system-kinds.js";
22
22
  import { resolveRefSentinels } from "./resolve-ref-sentinels.js";
23
+ import { resolveSchemaRefKinds, type RefConstraintIssue } from "./resolve-schema-ref-kinds.js";
23
24
  import { resolveSchemaTypeRefs } from "./resolve-schema-type-refs.js";
24
25
  import { validateSchemaTypeRefs } from "./validate-schema-type-refs.js";
25
26
  import { rewriteSyntheticOrigins } from "./rewrite-synthetic-origins.js";
@@ -29,6 +30,7 @@ import {
29
30
  validateAgainstSchema,
30
31
  type SchemaIssue,
31
32
  } from "./schema-compat.js";
33
+ import { collectValueSchemaIssues } from "./validate-value-schema.js";
32
34
  import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisOptions } from "./types.js";
33
35
  import {
34
36
  extractCelRegionScopes,
@@ -1034,6 +1036,7 @@ export class StaticAnalyzer {
1034
1036
  // declaring scope's resolver, so `extendedBy` is keyed by canonical kind regardless
1035
1037
  // of alias choices. `capability` covers the legacy implements-this-abstract overload;
1036
1038
  // `extends` is the canonical first-class form.
1039
+ const refConstraintIssues: RefConstraintIssue[] = [];
1037
1040
  for (const m of manifests) {
1038
1041
  if (m.kind !== "Telo.Definition" && m.kind !== "Telo.Abstract") continue;
1039
1042
  const def = m as unknown as ResourceDefinition;
@@ -1042,6 +1045,15 @@ export class StaticAnalyzer {
1042
1045
  ownModule && !rootModules.has(ownModule)
1043
1046
  ? (aliasesByModule.get(ownModule) ?? new AliasResolver())
1044
1047
  : aliases;
1048
+ // Canonicalize alias-form `x-telo-ref` constraints in the DECLARING module's
1049
+ // scope, before the schema reaches `register()` and the lazily-built field
1050
+ // maps. Same pre-resolution `capability` / `extends` get below.
1051
+ const issues = resolveSchemaRefKinds(m, scopeResolver);
1052
+ // Report only for definitions the author can edit. A published dependency
1053
+ // still on the deprecated form — or with a constraint that no longer
1054
+ // resolves — is not the consumer's to fix, and every import would
1055
+ // otherwise flood `telo check` with unactionable noise.
1056
+ if (!ownModule || rootModules.has(ownModule)) refConstraintIssues.push(...issues);
1045
1057
  const resolvedCapability = def.capability
1046
1058
  ? (scopeResolver.resolveKind(def.capability) ?? def.capability)
1047
1059
  : def.capability;
@@ -1062,6 +1074,57 @@ export class StaticAnalyzer {
1062
1074
  // distinguishable from the resolver's own substitution (after Phase 2/2.5
1063
1075
  // they are the same object).
1064
1076
  if (!options?.skipValidation) {
1077
+ for (const issue of refConstraintIssues) {
1078
+ // An `unknown` prefix is also what an ALREADY-CANONICAL value looks like
1079
+ // (`kv-store.Store` names a module, not an alias). Now that every kind is
1080
+ // registered, the registry separates the two — anything it resolves was
1081
+ // canonical, anything it doesn't names nothing at all.
1082
+ if (issue.reason === "unknown" && defs.resolve(issue.ref)) continue;
1083
+ const resource = {
1084
+ kind: issue.manifest.kind,
1085
+ name: issue.manifest.metadata?.name as string,
1086
+ };
1087
+ const filePath = (issue.manifest.metadata as { source?: string } | undefined)?.source;
1088
+ const data = { resource, filePath, path: issue.path };
1089
+ if (issue.reason === "legacy") {
1090
+ diagnostics.push({
1091
+ severity: DiagnosticSeverity.Warning,
1092
+ code: "X_TELO_REF_LEGACY_IDENTITY",
1093
+ source: SOURCE,
1094
+ message:
1095
+ `x-telo-ref '${issue.ref}' at '${issue.path}' uses the deprecated ` +
1096
+ `'<namespace>/<module>#<Kind>' form. Write the target as an alias-qualified kind ` +
1097
+ `instead — '<Alias>.<Kind>' for a module declared in this file's 'imports:' map, ` +
1098
+ `'Self.<Kind>' for a kind in this library, or 'Telo.<Kind>' for a built-in capability.`,
1099
+ data,
1100
+ });
1101
+ } else if (issue.reason === "gated") {
1102
+ diagnostics.push({
1103
+ severity: DiagnosticSeverity.Error,
1104
+ code: "KIND_NOT_EXPORTED",
1105
+ source: SOURCE,
1106
+ message:
1107
+ `x-telo-ref '${issue.ref}' at '${issue.path}' targets a kind module ` +
1108
+ `'${issue.gate?.module}' does not export. Add ` +
1109
+ `'${issue.ref.slice(issue.ref.indexOf(".") + 1)}' to that module's exports.kinds. ` +
1110
+ `Exported kinds: ${issue.gate?.exported.join(", ") || "(none)"}.`,
1111
+ data,
1112
+ });
1113
+ } else {
1114
+ diagnostics.push({
1115
+ severity: DiagnosticSeverity.Error,
1116
+ code: "X_TELO_REF_UNRESOLVED",
1117
+ source: SOURCE,
1118
+ message:
1119
+ `x-telo-ref '${issue.ref}' at '${issue.path}' names no kind. The prefix must be an ` +
1120
+ `import alias declared in this file's 'imports:' map, 'Self' for a kind in this ` +
1121
+ `library, or 'Telo' for a built-in capability. An unresolvable constraint would ` +
1122
+ `leave the slot accepting any resource. Known aliases: ` +
1123
+ `${issue.knownAliases?.join(", ") || "(none)"}.`,
1124
+ data,
1125
+ });
1126
+ }
1127
+ }
1065
1128
  diagnostics.push(...validateReferenceForms(manifests, defs, aliases, aliasesByModule));
1066
1129
  }
1067
1130
 
@@ -1088,10 +1151,31 @@ export class StaticAnalyzer {
1088
1151
  rootModules.has(ownModule) ? aliases : (aliasesByModule.get(ownModule) ?? new AliasResolver());
1089
1152
  const canonicalKind = scopeResolver.resolveKind(m.kind as string) ?? (m.kind as string);
1090
1153
  if (defs.resolve(canonicalKind)?.capability !== "Telo.Type") continue;
1091
- defs.registerNamedTypeSchema(
1092
- canonicalTypeSchemaId(ownModule, m.metadata.name as string),
1154
+ const typeName = m.metadata.name as string;
1155
+ const registered = defs.registerNamedTypeSchema(
1156
+ canonicalTypeSchemaId(ownModule, typeName),
1093
1157
  m.schema as Record<string, any>,
1094
1158
  );
1159
+ // Kinds and named types share one `telo://<module>/<Name>` id space. A
1160
+ // collision would leave every `$ref` to that id resolving to the kind's
1161
+ // schema — validating the wrong shape, silently — so it is an error, not
1162
+ // a last-writer-wins.
1163
+ if (!registered && !options?.skipValidation) {
1164
+ diagnostics.push({
1165
+ severity: DiagnosticSeverity.Error,
1166
+ code: "DUPLICATE_SCHEMA_ID",
1167
+ source: SOURCE,
1168
+ message:
1169
+ `Type '${typeName}' collides with the kind '${ownModule}.${typeName}': both claim the ` +
1170
+ `schema id '${canonicalTypeSchemaId(ownModule, typeName)}'. A '$ref' to it would ` +
1171
+ `resolve to the kind's schema. Rename one of them.`,
1172
+ data: {
1173
+ resource: { kind: m.kind, name: typeName },
1174
+ filePath: (m.metadata as { source?: string } | undefined)?.source,
1175
+ path: "metadata.name",
1176
+ },
1177
+ });
1178
+ }
1095
1179
  }
1096
1180
  if (!options?.skipValidation) {
1097
1181
  diagnostics.push(
@@ -1351,7 +1435,16 @@ export class StaticAnalyzer {
1351
1435
  );
1352
1436
  // Phase 2+3: AJV on substituted data — CEL fields replaced with typed placeholders
1353
1437
  const ajvIssues = validateAgainstSchema(substituteCelFields(m, schema), schema);
1354
- const issues = [...celIssues, ...ajvIssues];
1438
+ // Phase 4: value slots that must satisfy a type declared elsewhere on
1439
+ // the resource (`x-telo-value-schema-from`) — e.g. every row of a
1440
+ // decision table against its declared `outputType`, so a mistyped branch
1441
+ // is caught here rather than on the one input that selects it.
1442
+ const valueSchemaIssues = collectValueSchemaIssues(
1443
+ m as Record<string, any>,
1444
+ schema,
1445
+ allManifests as Record<string, any>[],
1446
+ );
1447
+ const issues = [...celIssues, ...ajvIssues, ...valueSchemaIssues];
1355
1448
  for (const issue of issues) {
1356
1449
  diagnostics.push({
1357
1450
  severity: DiagnosticSeverity.Error,
@@ -1390,6 +1483,7 @@ export class StaticAnalyzer {
1390
1483
  const viaRoot = aliases.resolveKind(kind);
1391
1484
  return viaRoot ? defs.resolve(viaRoot) : undefined;
1392
1485
  },
1486
+ allManifests as Record<string, any>[],
1393
1487
  ),
1394
1488
  );
1395
1489
  }
package/src/builtins.ts CHANGED
@@ -97,7 +97,7 @@ const ROOT_LOGGING_SCHEMA = {
97
97
  type: "array",
98
98
  items: {
99
99
  type: "object",
100
- "x-telo-ref": "telo#LogSink",
100
+ "x-telo-ref": "Telo.LogSink",
101
101
  "x-telo-inline": true,
102
102
  },
103
103
  },
@@ -444,8 +444,8 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
444
444
  type: "array",
445
445
  items: {
446
446
  anyOf: [
447
- { type: "string", "x-telo-ref": "telo#Runnable" },
448
- { type: "string", "x-telo-ref": "telo#Service" },
447
+ { type: "string", "x-telo-ref": "Telo.Runnable" },
448
+ { type: "string", "x-telo-ref": "Telo.Service" },
449
449
  // Post-resolution shape that `resolveRefSentinels`
450
450
  // substitutes a `!ref <name>` sentinel into. The
451
451
  // adjacent `x-telo-ref` constraints govern the kind
@@ -469,8 +469,8 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
469
469
  properties: {
470
470
  ref: {
471
471
  anyOf: [
472
- { type: "string", "x-telo-ref": "telo#Runnable" },
473
- { type: "string", "x-telo-ref": "telo#Service" },
472
+ { type: "string", "x-telo-ref": "Telo.Runnable" },
473
+ { type: "string", "x-telo-ref": "Telo.Service" },
474
474
  {
475
475
  type: "object",
476
476
  required: ["kind", "name"],
@@ -510,8 +510,8 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
510
510
  },
511
511
  additionalProperties: true,
512
512
  anyOf: [
513
- { "x-telo-ref": "telo#Invocable" },
514
- { "x-telo-ref": "telo#Runnable" },
513
+ { "x-telo-ref": "Telo.Invocable" },
514
+ { "x-telo-ref": "Telo.Runnable" },
515
515
  ],
516
516
  },
517
517
  inputs: { type: "object", additionalProperties: true },
@@ -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
- /** Module identity table: identity string → canonical module name.
30
- * "telo" → "Telo", "std/pipeline" → "pipeline", etc. */
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 for x-telo-ref resolution.
84
- * Call once per module doc (Telo.Application or Telo.Library) when the manifest is loaded.
85
- * @param namespace The module's metadata.namespace (e.g. "std"), or null for telo built-ins.
86
- * @param moduleName The module's metadata.name (e.g. "pipeline", "http-server"). */
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
- // The "telo" identity is reserved for the Telo built-in module and gets
89
- // populated automatically when a Telo.Abstract definition registers (see
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
- registerNamedTypeSchema(id: string, schema: Record<string, any>): void {
124
- if (this.registeredSchemaIds.has(id) || this.ajv.getSchema(id)) return;
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 = this.computeId(moduleName, typeName);
181
- if (!id || this.registeredSchemaIds.has(id)) return;
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 string to a canonical registry kind key.
190
- * Splits on "#", looks up the left side in the identity table, and returns
191
- * "<canonicalModule>.<TypeName>".
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
- * "telo#Invocable" → "Telo.Invocable"
194
- * "std/pipeline#Job" → "pipeline.Job"
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 the string is malformed or the identity is not registered. */
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 || hash === xTeloRef.length - 1) return undefined;
201
- const identity = xTeloRef.slice(0, hash);
202
- const typeName = xTeloRef.slice(hash + 1);
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}.${typeName}`;
206
+ return `${moduleName}.${xTeloRef.slice(hash + 1)}`;
206
207
  }
207
208
 
208
209
  resolve(kind: string): ResourceDefinition | undefined {
@@ -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/namespace on the edge means
54
- * `flattenForAnalyzer` can stamp `metadata.resolvedModuleName` /
55
- * `resolvedNamespace` from this single source rather than re-deriving
56
- * the target from manifest metadata, which would silently miss whenever
57
- * a future projection forgets to stamp `metadata.source` consistently. */
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` (or `null` when unset). */
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
 
@@ -317,6 +317,7 @@ export class Loader {
317
317
 
318
318
  aliases.set(alias, {
319
319
  targetSource: targetCanonical,
320
+ targetRef: importSource,
320
321
  targetModuleName,
321
322
  targetNamespace,
322
323
  });
@@ -83,24 +83,55 @@ function compareVersions(a: ParsedVersion, b: ParsedVersion): number {
83
83
  return 0;
84
84
  }
85
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 {
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; 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;
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: `${meta.namespace}/${name}`,
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 identity (`namespace/name`)
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.values()) {
205
- const info = moduleIdentityOf(mod);
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 ref strings ("namespace/module#TypeName" or "telo#TypeName").
3
+ /** One or more canonical kind keys ("<module>.<Kind>"), or the deprecated
4
+ * identity form ("<namespace>/<module>#<Kind>") for a legacy published module.
4
5
  * Multiple entries arise from anyOf branches. */
5
6
  refs: string[];
6
7
  /** True when the field path traversed through at least one array (path contains "[]"). */
@@ -210,7 +211,7 @@ function traverseNode(
210
211
  // the kernel's `runInvoke` emit-Invoked path. The walker fix and the
211
212
  // dispatcher fix need to land together — see the follow-up in
212
213
  // [kernel/nodejs/plans/reference-syntax-unification.md] and the
213
- // stopgap in `resource-context.ts:resolveChildren`. `visitedRefs`
214
+ // stopgap in `resource-context.ts:ensureKindRef`. `visitedRefs`
214
215
  // stays as a parameter so the recursive calls below thread the right
215
216
  // signature; turning the descent back on is a single-branch change.
216
217
  if (typeof node?.$ref === "string") return;