@telorun/analyzer 0.11.0 → 0.12.1

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 (88) hide show
  1. package/LICENSE +2 -2
  2. package/README.md +3 -3
  3. package/dist/analysis-registry.d.ts +7 -0
  4. package/dist/analysis-registry.d.ts.map +1 -1
  5. package/dist/analysis-registry.js +38 -0
  6. package/dist/analyzer.d.ts +15 -0
  7. package/dist/analyzer.d.ts.map +1 -1
  8. package/dist/analyzer.js +181 -11
  9. package/dist/builtins.d.ts.map +1 -1
  10. package/dist/builtins.js +58 -1
  11. package/dist/definition-registry.d.ts +12 -1
  12. package/dist/definition-registry.d.ts.map +1 -1
  13. package/dist/definition-registry.js +36 -1
  14. package/dist/dependency-graph.d.ts.map +1 -1
  15. package/dist/dependency-graph.js +27 -13
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +2 -0
  19. package/dist/kernel-globals.d.ts.map +1 -1
  20. package/dist/kernel-globals.js +9 -11
  21. package/dist/manifest-loader.d.ts +23 -1
  22. package/dist/manifest-loader.d.ts.map +1 -1
  23. package/dist/manifest-loader.js +66 -3
  24. package/dist/normalize-inline-resources.d.ts.map +1 -1
  25. package/dist/normalize-inline-resources.js +26 -14
  26. package/dist/position-metadata.d.ts +11 -2
  27. package/dist/position-metadata.d.ts.map +1 -1
  28. package/dist/position-metadata.js +18 -3
  29. package/dist/precompile.d.ts.map +1 -1
  30. package/dist/precompile.js +9 -1
  31. package/dist/reference-field-map.d.ts +22 -4
  32. package/dist/reference-field-map.d.ts.map +1 -1
  33. package/dist/reference-field-map.js +94 -26
  34. package/dist/residual-schema.d.ts +23 -0
  35. package/dist/residual-schema.d.ts.map +1 -0
  36. package/dist/residual-schema.js +45 -0
  37. package/dist/resolve-ref-sentinels.d.ts +27 -0
  38. package/dist/resolve-ref-sentinels.d.ts.map +1 -0
  39. package/dist/resolve-ref-sentinels.js +114 -0
  40. package/dist/rewrite-synthetic-origins.d.ts +10 -0
  41. package/dist/rewrite-synthetic-origins.d.ts.map +1 -0
  42. package/dist/rewrite-synthetic-origins.js +55 -0
  43. package/dist/schema-compat.d.ts +11 -1
  44. package/dist/schema-compat.d.ts.map +1 -1
  45. package/dist/schema-compat.js +25 -4
  46. package/dist/system-kinds.d.ts +25 -0
  47. package/dist/system-kinds.d.ts.map +1 -0
  48. package/dist/system-kinds.js +34 -0
  49. package/dist/types.d.ts +12 -0
  50. package/dist/types.d.ts.map +1 -1
  51. package/dist/validate-cel-context.d.ts +5 -0
  52. package/dist/validate-cel-context.d.ts.map +1 -1
  53. package/dist/validate-cel-context.js +27 -15
  54. package/dist/validate-nested-inline.d.ts +30 -0
  55. package/dist/validate-nested-inline.d.ts.map +1 -0
  56. package/dist/validate-nested-inline.js +129 -0
  57. package/dist/validate-provider-coherence.d.ts +23 -0
  58. package/dist/validate-provider-coherence.d.ts.map +1 -0
  59. package/dist/validate-provider-coherence.js +148 -0
  60. package/dist/validate-references.d.ts.map +1 -1
  61. package/dist/validate-references.js +141 -36
  62. package/dist/with-synthetic-positions.d.ts +28 -0
  63. package/dist/with-synthetic-positions.d.ts.map +1 -0
  64. package/dist/with-synthetic-positions.js +45 -0
  65. package/package.json +7 -4
  66. package/src/analysis-registry.ts +37 -0
  67. package/src/analyzer.ts +190 -13
  68. package/src/builtins.ts +58 -1
  69. package/src/definition-registry.ts +35 -1
  70. package/src/dependency-graph.ts +27 -14
  71. package/src/index.ts +2 -0
  72. package/src/kernel-globals.ts +9 -11
  73. package/src/manifest-loader.ts +69 -4
  74. package/src/normalize-inline-resources.ts +48 -13
  75. package/src/position-metadata.ts +18 -3
  76. package/src/precompile.ts +8 -1
  77. package/src/reference-field-map.ts +130 -25
  78. package/src/residual-schema.ts +49 -0
  79. package/src/resolve-ref-sentinels.ts +127 -0
  80. package/src/rewrite-synthetic-origins.ts +75 -0
  81. package/src/schema-compat.ts +25 -4
  82. package/src/system-kinds.ts +37 -0
  83. package/src/types.ts +12 -0
  84. package/src/validate-cel-context.ts +28 -15
  85. package/src/validate-nested-inline.ts +158 -0
  86. package/src/validate-provider-coherence.ts +166 -0
  87. package/src/validate-references.ts +138 -35
  88. package/src/with-synthetic-positions.ts +48 -0
@@ -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
+ }