@telorun/analyzer 0.11.0 → 0.12.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 (92) hide show
  1. package/LICENSE +2 -2
  2. package/README.md +3 -3
  3. package/dist/adapters/http-adapter.d.ts +10 -0
  4. package/dist/adapters/http-adapter.d.ts.map +1 -0
  5. package/dist/adapters/http-adapter.js +18 -0
  6. package/dist/adapters/node-adapter.d.ts +17 -0
  7. package/dist/adapters/node-adapter.d.ts.map +1 -0
  8. package/dist/adapters/node-adapter.js +71 -0
  9. package/dist/adapters/registry-adapter.d.ts +15 -0
  10. package/dist/adapters/registry-adapter.d.ts.map +1 -0
  11. package/dist/adapters/registry-adapter.js +53 -0
  12. package/dist/analysis-registry.d.ts +7 -0
  13. package/dist/analysis-registry.d.ts.map +1 -1
  14. package/dist/analysis-registry.js +38 -0
  15. package/dist/analyzer.d.ts +15 -0
  16. package/dist/analyzer.d.ts.map +1 -1
  17. package/dist/analyzer.js +114 -10
  18. package/dist/builtins.d.ts.map +1 -1
  19. package/dist/builtins.js +58 -1
  20. package/dist/definition-registry.d.ts.map +1 -1
  21. package/dist/definition-registry.js +16 -0
  22. package/dist/dependency-graph.d.ts.map +1 -1
  23. package/dist/dependency-graph.js +27 -13
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -0
  27. package/dist/kernel-globals.d.ts.map +1 -1
  28. package/dist/kernel-globals.js +9 -11
  29. package/dist/manifest-loader.d.ts +23 -1
  30. package/dist/manifest-loader.d.ts.map +1 -1
  31. package/dist/manifest-loader.js +66 -3
  32. package/dist/normalize-inline-resources.d.ts.map +1 -1
  33. package/dist/normalize-inline-resources.js +26 -14
  34. package/dist/position-metadata.d.ts +11 -2
  35. package/dist/position-metadata.d.ts.map +1 -1
  36. package/dist/position-metadata.js +18 -3
  37. package/dist/precompile.d.ts.map +1 -1
  38. package/dist/precompile.js +9 -1
  39. package/dist/reference-field-map.d.ts +21 -4
  40. package/dist/reference-field-map.d.ts.map +1 -1
  41. package/dist/reference-field-map.js +93 -25
  42. package/dist/residual-schema.d.ts +23 -0
  43. package/dist/residual-schema.d.ts.map +1 -0
  44. package/dist/residual-schema.js +45 -0
  45. package/dist/resolve-ref-sentinels.d.ts +27 -0
  46. package/dist/resolve-ref-sentinels.d.ts.map +1 -0
  47. package/dist/resolve-ref-sentinels.js +114 -0
  48. package/dist/rewrite-synthetic-origins.d.ts +10 -0
  49. package/dist/rewrite-synthetic-origins.d.ts.map +1 -0
  50. package/dist/rewrite-synthetic-origins.js +55 -0
  51. package/dist/schema-compat.d.ts +7 -1
  52. package/dist/schema-compat.d.ts.map +1 -1
  53. package/dist/schema-compat.js +19 -2
  54. package/dist/system-kinds.d.ts +25 -0
  55. package/dist/system-kinds.d.ts.map +1 -0
  56. package/dist/system-kinds.js +34 -0
  57. package/dist/types.d.ts +12 -0
  58. package/dist/types.d.ts.map +1 -1
  59. package/dist/validate-cel-context.d.ts +5 -0
  60. package/dist/validate-cel-context.d.ts.map +1 -1
  61. package/dist/validate-cel-context.js +27 -15
  62. package/dist/validate-provider-coherence.d.ts +23 -0
  63. package/dist/validate-provider-coherence.d.ts.map +1 -0
  64. package/dist/validate-provider-coherence.js +148 -0
  65. package/dist/validate-references.d.ts.map +1 -1
  66. package/dist/validate-references.js +141 -36
  67. package/dist/with-synthetic-positions.d.ts +28 -0
  68. package/dist/with-synthetic-positions.d.ts.map +1 -0
  69. package/dist/with-synthetic-positions.js +45 -0
  70. package/package.json +7 -4
  71. package/src/analysis-registry.ts +37 -0
  72. package/src/analyzer.ts +118 -12
  73. package/src/builtins.ts +58 -1
  74. package/src/definition-registry.ts +15 -0
  75. package/src/dependency-graph.ts +27 -14
  76. package/src/index.ts +2 -0
  77. package/src/kernel-globals.ts +9 -11
  78. package/src/manifest-loader.ts +69 -4
  79. package/src/normalize-inline-resources.ts +48 -13
  80. package/src/position-metadata.ts +18 -3
  81. package/src/precompile.ts +8 -1
  82. package/src/reference-field-map.ts +129 -24
  83. package/src/residual-schema.ts +49 -0
  84. package/src/resolve-ref-sentinels.ts +127 -0
  85. package/src/rewrite-synthetic-origins.ts +75 -0
  86. package/src/schema-compat.ts +19 -2
  87. package/src/system-kinds.ts +37 -0
  88. package/src/types.ts +12 -0
  89. package/src/validate-cel-context.ts +28 -15
  90. package/src/validate-provider-coherence.ts +166 -0
  91. package/src/validate-references.ts +138 -35
  92. package/src/with-synthetic-positions.ts +48 -0
@@ -1,16 +1,23 @@
1
1
  import AjvModule from "ajv";
2
2
  import addFormats from "ajv-formats";
3
- import { isTaggedSentinel } from "@telorun/templating";
3
+ import { isRefSentinel, isTaggedSentinel, ManifestRootSchema } from "@telorun/templating";
4
4
 
5
5
  const Ajv = (AjvModule as any).default ?? AjvModule;
6
6
 
7
7
  /** Creates a configured AJV instance (allErrors, strict: false, with formats).
8
- * Called once for the module-level instance and once per DefinitionRegistry instance. */
8
+ * Also registers the kernel manifest root schema under `telo://manifest` so
9
+ * module YAMLs can `$ref` into the shared `$defs/ResourceRef` (and any future
10
+ * shared fragments) from this analyzer's AJV without each module having to
11
+ * bundle its own copy.
12
+ *
13
+ * Called once for the module-level instance and once per
14
+ * DefinitionRegistry instance. */
9
15
  export function createAjv(): InstanceType<typeof Ajv> {
10
16
  const instance = new Ajv({ allErrors: true, strict: false });
11
17
  (addFormats as any).default
12
18
  ? (addFormats as any).default(instance)
13
19
  : (addFormats as any)(instance);
20
+ instance.addSchema(ManifestRootSchema);
14
21
  return instance;
15
22
  }
16
23
 
@@ -301,6 +308,16 @@ export function substituteCelFields(
301
308
  if (typeof data === "string" && CEL_PURE_RE.test(data)) {
302
309
  return celPlaceholderForSchema(resolved);
303
310
  }
311
+ // `!ref <name>` sentinels are identity markers, not runtime values —
312
+ // schemas that opt into `$ref: "telo://manifest#/$defs/ResourceRef"`
313
+ // (or `anyOf` it alongside other shapes) need the actual sentinel
314
+ // object so AJV validates it against ResourceRefSchema. Collapsing it
315
+ // to a CEL placeholder would either fail the schema (when the slot
316
+ // expects the ResourceRef shape) or mask validation errors (when the
317
+ // slot expects something else entirely).
318
+ if (isRefSentinel(data)) {
319
+ return data;
320
+ }
304
321
  if (isTaggedSentinel(data)) {
305
322
  return celPlaceholderForSchema(resolved);
306
323
  }
@@ -0,0 +1,37 @@
1
+ /** Resource-kind sets used by analysis passes to decide what counts as a
2
+ * user-defined instance vs. a system-level blueprint. Pulled into one
3
+ * place so the three passes (reference validation, dependency graph,
4
+ * ref-sentinel resolution) don't drift; each pass exports its own
5
+ * scoped view with a comment explaining what's in and what's out. */
6
+
7
+ /** Skipped by reference validation: type blueprints whose own ref slots
8
+ * belong to a different phase (definition schema validation rather than
9
+ * per-resource validation). Telo.Application and Telo.Library
10
+ * intentionally fall through — Application has `targets` (real refs) and
11
+ * Library is harmless (no ref-bearing fields). Telo.Import is also
12
+ * intentionally not skipped — its `source` is not an x-telo-ref slot, so
13
+ * walking it is cheap and consistent. */
14
+ export const REF_VALIDATION_SKIP_KINDS: ReadonlySet<string> = new Set([
15
+ "Telo.Definition",
16
+ "Telo.Abstract",
17
+ ]);
18
+
19
+ /** Excluded from the dependency graph: kinds that are not runtime nodes.
20
+ * Telo.Abstract is intentionally not in this set today — abstracts have
21
+ * no resource manifests, so they never reach graph construction; if
22
+ * that ever changes, add it explicitly. */
23
+ export const DEPENDENCY_GRAPH_SKIP_KINDS: ReadonlySet<string> = new Set([
24
+ "Telo.Definition",
25
+ "Telo.Import",
26
+ ]);
27
+
28
+ /** Skipped by `!ref` sentinel resolution: kinds whose bodies are
29
+ * blueprints or import-time metadata, not resource instances with
30
+ * user-referenced ref slots. Mirrors `REF_VALIDATION_SKIP_KINDS` but
31
+ * also drops Telo.Import (its `source` isn't a ref slot, and walking
32
+ * the field map on it is pointless since there's no registered kind). */
33
+ export const REF_RESOLUTION_SKIP_KINDS: ReadonlySet<string> = new Set([
34
+ "Telo.Definition",
35
+ "Telo.Abstract",
36
+ "Telo.Import",
37
+ ]);
package/src/types.ts CHANGED
@@ -81,6 +81,18 @@ export interface LoaderInitOptions {
81
81
 
82
82
  export interface AnalysisOptions {
83
83
  strictContexts?: boolean;
84
+ /** When true, `analyze()` runs the state-mutating setup (module identity /
85
+ * alias / definition registration plus `normalizeInlineResources`) but
86
+ * skips every diagnostic-producing pass — per-resource validation, the
87
+ * Library `env:` check, `validateExtends`, `validateProviderCoherence`,
88
+ * and `validateThrowsCoverage`. Used by the kernel when a previous load
89
+ * has already stamped the manifest set as valid (by content hash), so
90
+ * the registry still gets populated without paying the validation walk
91
+ * on every cold start. The caller takes responsibility for the
92
+ * correctness guarantee — pass this only when something durable
93
+ * (on-disk stamp) attests that the manifests passed a real analyze
94
+ * pass at the same analyzer / kernel version. */
95
+ skipValidation?: boolean;
84
96
  }
85
97
 
86
98
  /** Pre-seeded state for incremental analysis. Passed to StaticAnalyzer.analyze() so it does
@@ -119,6 +119,11 @@ export function pathMatchesScope(exprPath: string, scope: string): boolean {
119
119
  * `manifestRoot.provide.kind` as a kind name, looks up the kind's Telo.Definition,
120
120
  * and returns the `outputType` schema.
121
121
  *
122
+ * Accepts either a single string or an array of strings. With an array, paths
123
+ * are tried in order and the first one that resolves to a usable schema wins —
124
+ * used by `result:` to find its dispatch target under whichever entry-point
125
+ * field (`provide:` or `invoke:`) the definition declares.
126
+ *
122
127
  * - `x-telo-context-ref-from`: existing form — reads `{kind, name}` object from
123
128
  * `manifestItem.<path>`, looks up the named manifest, returns its `<subpath>` field.
124
129
  *
@@ -153,8 +158,16 @@ export function resolveContextAnnotations(
153
158
  }
154
159
 
155
160
  const fromRoot = schema["x-telo-context-from-root"] as string | undefined;
156
- const fromRefKind = schema["x-telo-context-from-ref-kind"] as string | undefined;
157
- if (fromRoot || fromRefKind) {
161
+ const fromRefKindRaw = schema["x-telo-context-from-ref-kind"] as
162
+ | string
163
+ | string[]
164
+ | undefined;
165
+ const fromRefKinds = fromRefKindRaw == null
166
+ ? []
167
+ : Array.isArray(fromRefKindRaw)
168
+ ? fromRefKindRaw
169
+ : [fromRefKindRaw];
170
+ if (fromRoot || fromRefKinds.length > 0) {
158
171
  if (fromRoot) {
159
172
  const resolved = navigatePath(manifestRoot, fromRoot.split("/")) as
160
173
  | Record<string, any>
@@ -163,22 +176,22 @@ export function resolveContextAnnotations(
163
176
  return resolved;
164
177
  }
165
178
  }
166
- if (fromRefKind && defs) {
167
- const hashIdx = fromRefKind.indexOf("#");
168
- if (hashIdx > 0) {
179
+ if (defs) {
180
+ for (const fromRefKind of fromRefKinds) {
181
+ const hashIdx = fromRefKind.indexOf("#");
182
+ if (hashIdx <= 0) continue;
169
183
  const refPath = fromRefKind.slice(0, hashIdx);
170
184
  const field = fromRefKind.slice(hashIdx + 1);
171
185
  const kindValue = navigatePath(manifestRoot, refPath.split("/"));
172
- if (typeof kindValue === "string" && kindValue.length > 0) {
173
- const canonical = aliases?.resolveKind(kindValue) ?? kindValue;
174
- const def = defs.resolve(canonical);
175
- const typeField = def
176
- ? (def as Record<string, unknown>)[field]
177
- : undefined;
178
- const resolved = resolveTypeFieldToSchema(typeField, allManifests ?? []);
179
- if (resolved && typeof resolved === "object") {
180
- return resolved;
181
- }
186
+ if (typeof kindValue !== "string" || kindValue.length === 0) continue;
187
+ const canonical = aliases?.resolveKind(kindValue) ?? kindValue;
188
+ const def = defs.resolve(canonical);
189
+ const typeField = def
190
+ ? (def as Record<string, unknown>)[field]
191
+ : undefined;
192
+ const resolved = resolveTypeFieldToSchema(typeField, allManifests ?? []);
193
+ if (resolved && typeof resolved === "object") {
194
+ return resolved;
182
195
  }
183
196
  }
184
197
  }
@@ -0,0 +1,166 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import type { AliasResolver } from "./alias-resolver.js";
3
+ import type { DefinitionRegistry } from "./definition-registry.js";
4
+ import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
5
+
6
+ const SOURCE = "telo-analyzer";
7
+
8
+ /**
9
+ * Validates coherence rules for `Telo.Definition` documents that use the `provide:`
10
+ * template target, plus the implementation-presence rule on `Telo.Provider`
11
+ * definitions.
12
+ *
13
+ * Diagnostics:
14
+ * - PROVIDE_ON_NON_PROVIDER: `provide:` declared on a definition whose
15
+ * `capability` is not `Telo.Provider`.
16
+ * - PROVIDE_DISPATCHER_CONFLICT: `provide:` co-exists with `invoke:` or `run:`
17
+ * on the same definition.
18
+ * - PROVIDE_TARGET_UNKNOWN: `provide.name` does not resolve to an entry in
19
+ * `resources:`.
20
+ * - PROVIDE_TARGET_NOT_INVOCABLE: `provide.name` resolves to a resource whose
21
+ * kind is registered but not a `Telo.Invocable`.
22
+ * - PROVIDER_MISSING_IMPLEMENTATION: definition with `capability: Telo.Provider`
23
+ * declares neither `controllers:` (TS-backed) nor `provide:` (template-backed).
24
+ */
25
+ export function validateProviderCoherence(
26
+ manifests: ResourceManifest[],
27
+ registry: DefinitionRegistry,
28
+ aliases: AliasResolver,
29
+ ): AnalysisDiagnostic[] {
30
+ const diagnostics: AnalysisDiagnostic[] = [];
31
+
32
+ const importedModules = new Set<string>();
33
+ for (const m of manifests) {
34
+ if (m.kind !== "Telo.Import") continue;
35
+ const resolved = (m.metadata as { resolvedModuleName?: string } | undefined)
36
+ ?.resolvedModuleName;
37
+ if (resolved) importedModules.add(resolved);
38
+ }
39
+
40
+ for (const m of manifests) {
41
+ if (m.kind !== "Telo.Definition") continue;
42
+ const name = m.metadata?.name as string | undefined;
43
+ if (!name) continue;
44
+ const ownModule = (m.metadata as { module?: string } | undefined)?.module;
45
+ if (ownModule && importedModules.has(ownModule)) continue;
46
+ const filePath = (m.metadata as { source?: string } | undefined)?.source;
47
+ const resource = { kind: m.kind, name };
48
+ const label = `${m.kind}/${name}`;
49
+
50
+ const md = m as Record<string, unknown>;
51
+ const capability = typeof md.capability === "string" ? md.capability : undefined;
52
+ const provide = md.provide;
53
+ const invoke = md.invoke;
54
+ const run = md.run;
55
+ const controllers = md.controllers;
56
+ const resources = md.resources;
57
+
58
+ const hasProvide = provide !== undefined && provide !== null;
59
+ const hasInvoke = invoke !== undefined && invoke !== null;
60
+ const hasRun = run !== undefined && run !== null;
61
+ const hasControllers = Array.isArray(controllers) && controllers.length > 0;
62
+
63
+ if (hasProvide && capability !== "Telo.Provider") {
64
+ diagnostics.push({
65
+ severity: DiagnosticSeverity.Error,
66
+ code: "PROVIDE_ON_NON_PROVIDER",
67
+ source: SOURCE,
68
+ message:
69
+ `${label}: 'provide:' is only valid on definitions with 'capability: Telo.Provider' ` +
70
+ `(found '${capability ?? "<unset>"}'). Use 'invoke:' or 'run:' for other capabilities.`,
71
+ data: { resource, filePath, path: "provide" },
72
+ });
73
+ }
74
+
75
+ if (hasProvide && (hasInvoke || hasRun)) {
76
+ const conflict = hasInvoke ? "invoke" : "run";
77
+ diagnostics.push({
78
+ severity: DiagnosticSeverity.Error,
79
+ code: "PROVIDE_DISPATCHER_CONFLICT",
80
+ source: SOURCE,
81
+ message:
82
+ `${label}: 'provide:' cannot co-exist with '${conflict}:'. ` +
83
+ `A definition declares exactly one dispatch entry-point.`,
84
+ data: { resource, filePath, path: "provide" },
85
+ });
86
+ }
87
+
88
+ if (hasProvide && typeof provide === "object" && !Array.isArray(provide)) {
89
+ const provideObj = provide as { kind?: unknown; name?: unknown };
90
+ const providedName = typeof provideObj.name === "string" ? provideObj.name : undefined;
91
+ const providedKind = typeof provideObj.kind === "string" ? provideObj.kind : undefined;
92
+ if (providedName && Array.isArray(resources)) {
93
+ const match = resources.find((r) => {
94
+ const meta = (r as { metadata?: { name?: unknown } })?.metadata;
95
+ return typeof meta?.name === "string" && meta.name === providedName;
96
+ }) as { kind?: unknown } | undefined;
97
+ if (!match) {
98
+ diagnostics.push({
99
+ severity: DiagnosticSeverity.Error,
100
+ code: "PROVIDE_TARGET_UNKNOWN",
101
+ source: SOURCE,
102
+ message:
103
+ `${label}: 'provide.name: ${providedName}' does not match any entry's ` +
104
+ `metadata.name in 'resources:'.`,
105
+ data: { resource, filePath, path: "provide.name" },
106
+ });
107
+ } else if (typeof match.kind === "string") {
108
+ // `provide.kind` is the type contract the analyzer uses to type
109
+ // `result` CEL against the target's `outputType`. The runtime
110
+ // dispatches on `provide.name` and ignores `provide.kind`, so a
111
+ // mismatch silently degrades `result` typing to an open schema
112
+ // (and at runtime quietly invokes the actually-matched resource).
113
+ // Flag the divergence so result-typing never lies.
114
+ if (providedKind) {
115
+ const providedCanonical = aliases.resolveKind(providedKind) ?? providedKind;
116
+ const matchCanonical = aliases.resolveKind(match.kind) ?? match.kind;
117
+ if (providedCanonical !== matchCanonical) {
118
+ diagnostics.push({
119
+ severity: DiagnosticSeverity.Error,
120
+ code: "PROVIDE_KIND_MISMATCH",
121
+ source: SOURCE,
122
+ message:
123
+ `${label}: 'provide.kind: ${providedKind}' disagrees with the matched ` +
124
+ `'resources:' entry's kind '${match.kind}' (matched by metadata.name ` +
125
+ `'${providedName}'). The runtime dispatches by name, so 'provide.kind' ` +
126
+ `is decorative — but the analyzer types 'result:' against it, and a ` +
127
+ `mismatch silently turns off that typing.`,
128
+ data: { resource, filePath, path: "provide.kind" },
129
+ });
130
+ }
131
+ }
132
+ const resolvedKind = aliases.resolveKind(match.kind) ?? match.kind;
133
+ const targetDef = registry.resolve(resolvedKind) ?? registry.resolve(match.kind);
134
+ if (targetDef && targetDef.kind === "Telo.Definition") {
135
+ const targetCap = (targetDef as { capability?: unknown }).capability;
136
+ if (typeof targetCap === "string" && targetCap !== "Telo.Invocable") {
137
+ diagnostics.push({
138
+ severity: DiagnosticSeverity.Error,
139
+ code: "PROVIDE_TARGET_NOT_INVOCABLE",
140
+ source: SOURCE,
141
+ message:
142
+ `${label}: 'provide.name: ${providedName}' resolves to a ${match.kind} ` +
143
+ `(capability '${targetCap}'); 'provide:' requires a Telo.Invocable target.`,
144
+ data: { resource, filePath, path: "provide.name" },
145
+ });
146
+ }
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ if (capability === "Telo.Provider" && !hasControllers && !hasProvide) {
153
+ diagnostics.push({
154
+ severity: DiagnosticSeverity.Error,
155
+ code: "PROVIDER_MISSING_IMPLEMENTATION",
156
+ source: SOURCE,
157
+ message:
158
+ `${label}: 'capability: Telo.Provider' requires either 'controllers:' ` +
159
+ `(TS-backed) or 'provide:' (template-backed) to declare an implementation.`,
160
+ data: { resource, filePath, path: "capability" },
161
+ });
162
+ }
163
+ }
164
+
165
+ return diagnostics;
166
+ }
@@ -1,17 +1,13 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
- import { isRefEntry, isScopeEntry, isSchemaFromEntry, isInlineResource, resolveFieldValues, type RefFieldEntry } from "./reference-field-map.js";
2
+ import { isRefSentinel } from "@telorun/templating";
3
+ import { isRefEntry, isScopeEntry, isSchemaFromEntry, isInlineResource, resolveFieldEntries, resolveFieldValues, type RefFieldEntry } from "./reference-field-map.js";
3
4
  import { navigateJsonPointer } from "./schema-compat.js";
5
+ import { REF_VALIDATION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
4
6
  import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisContext } from "./types.js";
5
7
  import type { AliasResolver } from "./alias-resolver.js";
6
8
  import type { DefinitionRegistry } from "./definition-registry.js";
7
9
 
8
10
  const SOURCE = "telo-analyzer";
9
- /** Kinds skipped by reference validation. Telo.Application and Telo.Library
10
- * are intentionally not here: Application has `targets` with x-telo-ref that
11
- * must be validated, and Library has no ref-bearing fields so flows through
12
- * harmlessly. Telo.Import is also not here for the same reason — its
13
- * `source` field isn't x-telo-ref, so nothing gets checked. */
14
- const SYSTEM_KINDS = new Set(["Telo.Definition", "Telo.Abstract"]);
15
11
 
16
12
  /**
17
13
  * Checks whether `kind` satisfies the ref constraint in `entry`.
@@ -76,13 +72,88 @@ export function validateReferences(
76
72
  const aliasesByModule = context.aliasesByModule;
77
73
  if (!aliases || !registry) return diagnostics;
78
74
 
79
- // Build outer resource lookup by name for resolution check.
80
- // Exclude system kinds (Telo.Definition) they are type blueprints, not instances,
81
- // and their names (e.g. "Server", "Job") would shadow user-defined resource instances.
82
- const byName = new Map<string, ResourceManifest>();
75
+ // Build outer resource lookup by name for resolution check, collecting
76
+ // every entry per name so we can surface name collisions as diagnostics
77
+ // (the kernel's resource registry shares one namespace across all
78
+ // non-system kinds e.g. `Telo.Application HelloApi` and `Http.Api
79
+ // HelloApi` collide at boot with `ERR_DUPLICATE_RESOURCE`. Catching it
80
+ // statically removes a class of "everything analyzes clean, then the
81
+ // kernel refuses to start" surprises.)
82
+ //
83
+ // Telo.Import is excluded from the duplicate check on top of the
84
+ // SYSTEM_KINDS skip: its `metadata.name` is an alias, not a resource
85
+ // identity (aliases live in a separate namespace from resources, and
86
+ // colliding aliases vs. resource names is benign — the alias is only
87
+ // ever read as a kind prefix).
88
+ // Group manifests by name to detect collisions. Two subtleties:
89
+ //
90
+ // 1. Some analyzer hosts emit the SAME physical document twice through
91
+ // their pipeline — e.g. the telo-editor's `toAnalysisManifests` walks
92
+ // each workspace module's documents independently, and a file
93
+ // reachable from two angles (entry module + `include:` partial)
94
+ // shows up twice. The fingerprint includes `sourceLine` so identical
95
+ // docs (same kind, name, source, AND source line) collapse to one,
96
+ // while two textually-separate documents in the same file (different
97
+ // source lines) keep separate fingerprints and trip the diagnostic.
98
+ // 2. The diagnostic carries a precomputed `range` pointing at the
99
+ // duplicate's source line — editor hosts that resolve diagnostic
100
+ // positions via a `${file}::${kind}::${name}` lookup would otherwise
101
+ // collide on duplicates (Map.set overwrites) and place the squiggle
102
+ // ambiguously. The explicit `range` short-circuits that lookup.
103
+ // Dedup pipeline echoes — the same physical document emitted twice
104
+ // through an analyzer host's pipeline. Keyed on (kind, name, source,
105
+ // sourceLine), so two textually-distinct docs in the same file (same
106
+ // source, different sourceLine) keep separate fingerprints and still
107
+ // trip the diagnostic. `analyze()` enforces that every non-system
108
+ // manifest carries both positional fields — no defensive guard needed.
109
+ const byNameAll = new Map<string, ResourceManifest[]>();
110
+ const seen = new Set<string>();
83
111
  for (const r of resources) {
84
- if (r.metadata?.name && !SYSTEM_KINDS.has(r.kind)) byName.set(r.metadata.name as string, r);
112
+ if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || r.kind === "Telo.Import") continue;
113
+ const name = r.metadata.name as string;
114
+ // `analyze()` guarantees both fields are present on non-system manifests.
115
+ const meta = r.metadata as unknown as { source: string; sourceLine: number };
116
+ const fingerprint = `${r.kind} ${name} ${meta.source} ${meta.sourceLine}`;
117
+ if (seen.has(fingerprint)) continue;
118
+ seen.add(fingerprint);
119
+ const existing = byNameAll.get(name);
120
+ if (existing) existing.push(r);
121
+ else byNameAll.set(name, [r]);
122
+ }
123
+ for (const [name, list] of byNameAll) {
124
+ if (list.length <= 1) continue;
125
+ const [first, ...rest] = list;
126
+ const firstLabel = `${first.kind}/${name}`;
127
+ for (const dup of rest) {
128
+ const dupMeta = dup.metadata as { source?: string; sourceLine?: number } | undefined;
129
+ const range =
130
+ typeof dupMeta?.sourceLine === "number"
131
+ ? {
132
+ start: { line: dupMeta.sourceLine, character: 0 },
133
+ end: { line: dupMeta.sourceLine, character: Number.MAX_SAFE_INTEGER },
134
+ }
135
+ : undefined;
136
+ diagnostics.push({
137
+ severity: DiagnosticSeverity.Error,
138
+ code: "DUPLICATE_RESOURCE_NAME",
139
+ source: SOURCE,
140
+ message: `${dup.kind}/${name}: resource name collides with ${firstLabel} declared earlier (kernel runtime would fail with ERR_DUPLICATE_RESOURCE)`,
141
+ ...(range ? { range } : {}),
142
+ data: {
143
+ resource: { kind: dup.kind, name },
144
+ filePath: dupMeta?.source,
145
+ path: "metadata.name",
146
+ },
147
+ });
148
+ }
85
149
  }
150
+ // Single-resource map for the resolution / scope lookups below — when a
151
+ // collision exists, falling back to the first occurrence keeps the rest
152
+ // of the pass behaving the same as before the duplicate diagnostic was
153
+ // added (resolution still finds *something*; the duplicate diagnostic
154
+ // is what surfaces the underlying problem to the user).
155
+ const byName = new Map<string, ResourceManifest>();
156
+ for (const [name, list] of byNameAll) byName.set(name, list[0]);
86
157
 
87
158
  for (const r of resources) {
88
159
  if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
@@ -142,9 +213,41 @@ export function validateReferences(
142
213
  }
143
214
  }
144
215
 
145
- for (const val of resolveFieldValues(r, fieldPath)) {
216
+ for (const { value: val, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
146
217
  if (!val) continue;
147
218
 
219
+ // `!ref <name>` sentinel — bare resource name marked at parse time as a
220
+ // reference. Look it up against the slot's x-telo-ref constraint exactly
221
+ // like the legacy bare-string path; the only difference is the value's
222
+ // shape (a TaggedSentinel rather than a raw string), which removed the
223
+ // string/inline ambiguity at the source.
224
+ if (isRefSentinel(val)) {
225
+ const refName = val.source;
226
+ const target =
227
+ byName.get(refName) ?? visibleScopeManifests.find((m) => m.metadata?.name === refName);
228
+ if (!target) {
229
+ diagnostics.push({
230
+ severity: DiagnosticSeverity.Error,
231
+ code: "UNRESOLVED_REFERENCE",
232
+ source: SOURCE,
233
+ message: `${resourceLabel}: reference at '${concretePath}' → resource '${refName}' not found`,
234
+ data: { resource: resourceData, filePath, path: concretePath },
235
+ });
236
+ continue;
237
+ }
238
+ const kindErrors = checkKind(target.kind as string, entry, registry, aliases);
239
+ if (kindErrors.length > 0) {
240
+ diagnostics.push({
241
+ severity: DiagnosticSeverity.Error,
242
+ code: "REFERENCE_KIND_MISMATCH",
243
+ source: SOURCE,
244
+ message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
245
+ data: { resource: resourceData, filePath, path: concretePath },
246
+ });
247
+ }
248
+ continue;
249
+ }
250
+
148
251
  // Name-only reference (plain string) — look up by name to validate.
149
252
  // Qualified references use "Kind.Name" format (e.g. "Http.Api.PaymentApi");
150
253
  // extract the resource name from the last dot segment.
@@ -166,8 +269,8 @@ export function validateReferences(
166
269
  severity: DiagnosticSeverity.Error,
167
270
  code: "UNRESOLVED_REFERENCE",
168
271
  source: SOURCE,
169
- message: `${resourceLabel}: reference at '${fieldPath}' → resource '${val}' not found`,
170
- data: { resource: resourceData, filePath, path: fieldPath },
272
+ message: `${resourceLabel}: reference at '${concretePath}' → resource '${val}' not found`,
273
+ data: { resource: resourceData, filePath, path: concretePath },
171
274
  });
172
275
  continue;
173
276
  }
@@ -177,8 +280,8 @@ export function validateReferences(
177
280
  severity: DiagnosticSeverity.Error,
178
281
  code: "REFERENCE_KIND_MISMATCH",
179
282
  source: SOURCE,
180
- message: `${resourceLabel}: reference at '${fieldPath}' → ${kindErrors.join("; ")}`,
181
- data: { resource: resourceData, filePath, path: fieldPath },
283
+ message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
284
+ data: { resource: resourceData, filePath, path: concretePath },
182
285
  });
183
286
  }
184
287
  continue;
@@ -196,8 +299,8 @@ export function validateReferences(
196
299
  severity: DiagnosticSeverity.Error,
197
300
  code: "INVALID_REFERENCE",
198
301
  source: SOURCE,
199
- message: `${resourceLabel}: reference at '${fieldPath}' must have string 'kind' and 'name' fields`,
200
- data: { resource: resourceData, filePath, path: fieldPath },
302
+ message: `${resourceLabel}: reference at '${concretePath}' must have string 'kind' and 'name' fields`,
303
+ data: { resource: resourceData, filePath, path: concretePath },
201
304
  });
202
305
  continue;
203
306
  }
@@ -209,8 +312,8 @@ export function validateReferences(
209
312
  severity: DiagnosticSeverity.Error,
210
313
  code: "REFERENCE_KIND_MISMATCH",
211
314
  source: SOURCE,
212
- message: `${resourceLabel}: reference at '${fieldPath}' → ${kindErrors.join("; ")}`,
213
- data: { resource: resourceData, filePath, path: fieldPath },
315
+ message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
316
+ data: { resource: resourceData, filePath, path: concretePath },
214
317
  });
215
318
  }
216
319
 
@@ -223,8 +326,8 @@ export function validateReferences(
223
326
  severity: DiagnosticSeverity.Error,
224
327
  code: "UNRESOLVED_REFERENCE",
225
328
  source: SOURCE,
226
- message: `${resourceLabel}: reference at '${fieldPath}' → resource '${refVal.name}' not found`,
227
- data: { resource: resourceData, filePath, path: fieldPath },
329
+ message: `${resourceLabel}: reference at '${concretePath}' → resource '${refVal.name}' not found`,
330
+ data: { resource: resourceData, filePath, path: concretePath },
228
331
  });
229
332
  }
230
333
  }
@@ -317,7 +420,7 @@ export function validateReferences(
317
420
  continue;
318
421
  }
319
422
 
320
- for (const fieldValue of resolveFieldValues(r, fieldPath)) {
423
+ for (const { value: fieldValue, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
321
424
  if (fieldValue == null) continue;
322
425
  const issues = registry.validateWithRefs(fieldValue, subSchema as Record<string, any>);
323
426
  for (const issue of issues) {
@@ -325,8 +428,8 @@ export function validateReferences(
325
428
  severity: DiagnosticSeverity.Error,
326
429
  code: "DEPENDENT_SCHEMA_MISMATCH",
327
430
  source: SOURCE,
328
- message: `${resourceLabel}: '${fieldPath}' does not match schema from '${anchorName}${jsonPointer}': ${issue}`,
329
- data: { resource: resourceData, filePath, path: fieldPath },
431
+ message: `${resourceLabel}: '${concretePath}' does not match schema from '${anchorName}${jsonPointer}': ${issue}`,
432
+ data: { resource: resourceData, filePath, path: concretePath },
330
433
  });
331
434
  }
332
435
  }
@@ -347,10 +450,10 @@ export function validateReferences(
347
450
  const anchorValues = resolveFieldValues(r, anchorPath);
348
451
  if (anchorValues.length === 0) continue; // anchor field not set — nothing to validate
349
452
 
350
- const fieldValues = resolveFieldValues(r, fieldPath);
453
+ const fieldEntries = resolveFieldEntries(r, fieldPath);
351
454
 
352
- for (let i = 0; i < fieldValues.length; i++) {
353
- const fieldValue = fieldValues[i];
455
+ for (let i = 0; i < fieldEntries.length; i++) {
456
+ const { value: fieldValue, path: concretePath } = fieldEntries[i];
354
457
  if (fieldValue == null) continue;
355
458
 
356
459
  // For absolute paths, the single anchor applies to all field values.
@@ -367,8 +470,8 @@ export function validateReferences(
367
470
  severity: DiagnosticSeverity.Error,
368
471
  code: "SCHEMA_FROM_MISSING_PATH",
369
472
  source: SOURCE,
370
- message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → kind '${refVal.kind}' has no schema`,
371
- data: { resource: resourceData, filePath, path: fieldPath },
473
+ message: `${resourceLabel}: x-telo-schema-from at '${concretePath}' → kind '${refVal.kind}' has no schema`,
474
+ data: { resource: resourceData, filePath, path: concretePath },
372
475
  });
373
476
  continue;
374
477
  }
@@ -379,8 +482,8 @@ export function validateReferences(
379
482
  severity: DiagnosticSeverity.Error,
380
483
  code: "SCHEMA_FROM_MISSING_PATH",
381
484
  source: SOURCE,
382
- message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → kind '${refVal.kind}' has no schema path '${jsonPointer}'`,
383
- data: { resource: resourceData, filePath, path: fieldPath },
485
+ message: `${resourceLabel}: x-telo-schema-from at '${concretePath}' → kind '${refVal.kind}' has no schema path '${jsonPointer}'`,
486
+ data: { resource: resourceData, filePath, path: concretePath },
384
487
  });
385
488
  continue;
386
489
  }
@@ -391,8 +494,8 @@ export function validateReferences(
391
494
  severity: DiagnosticSeverity.Error,
392
495
  code: "DEPENDENT_SCHEMA_MISMATCH",
393
496
  source: SOURCE,
394
- message: `${resourceLabel}: '${fieldPath}' does not match schema from '${refVal.kind}${jsonPointer}': ${issue}`,
395
- data: { resource: resourceData, filePath, path: fieldPath },
497
+ message: `${resourceLabel}: '${concretePath}' does not match schema from '${refVal.kind}${jsonPointer}': ${issue}`,
498
+ data: { resource: resourceData, filePath, path: concretePath },
396
499
  });
397
500
  }
398
501
  }
@@ -0,0 +1,48 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import { REF_VALIDATION_SKIP_KINDS } from "./system-kinds.js";
3
+
4
+ /**
5
+ * Stamp `metadata.source` and `metadata.sourceLine` on every non-system
6
+ * manifest that lacks them, returning a new array with cloned `metadata`
7
+ * objects for the affected entries.
8
+ *
9
+ * `StaticAnalyzer.analyze()` requires position info on every non-system
10
+ * manifest (the dedup that backs `DUPLICATE_RESOURCE_NAME` reads
11
+ * `(source, sourceLine)` to distinguish pipeline echoes from real
12
+ * collisions). Production callers — the `Loader`, `flattenForAnalyzer`,
13
+ * the telo-editor's `emitDocsFor`, the VSCode extension — all stamp
14
+ * positions already. This helper is the escape hatch for **programmatic
15
+ * callers** (tests, ad-hoc scripts) that construct `ResourceManifest`
16
+ * literals without going through a loader: it gives every otherwise-naked
17
+ * manifest a synthetic, deterministic position so the analyzer's
18
+ * invariant holds without each test having to spell positions out.
19
+ *
20
+ * The synthetic source defaults to `"<programmatic>"` — override via
21
+ * `source` when a stable, recognisable label helps diagnostic output.
22
+ * Each unstamped manifest gets a unique `sourceLine` (1-based array
23
+ * index) so two real duplicates supplied without positions retain
24
+ * distinct fingerprints and still trip `DUPLICATE_RESOURCE_NAME`.
25
+ *
26
+ * Manifests that already carry `metadata.source` and `metadata.sourceLine`
27
+ * pass through unchanged.
28
+ */
29
+ export function withSyntheticPositions(
30
+ manifests: ResourceManifest[],
31
+ source: string = "<programmatic>",
32
+ ): ResourceManifest[] {
33
+ return manifests.map((m, i) => {
34
+ if (REF_VALIDATION_SKIP_KINDS.has(m.kind)) return m;
35
+ const meta = m.metadata as { source?: string; sourceLine?: number } | undefined;
36
+ const hasSource = typeof meta?.source === "string" && meta.source.length > 0;
37
+ const hasLine = typeof meta?.sourceLine === "number";
38
+ if (hasSource && hasLine) return m;
39
+ return {
40
+ ...m,
41
+ metadata: {
42
+ ...m.metadata,
43
+ source: hasSource ? meta!.source : source,
44
+ sourceLine: hasLine ? meta!.sourceLine : i,
45
+ },
46
+ } as ResourceManifest;
47
+ });
48
+ }