@telorun/analyzer 1.1.0 → 1.3.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 (42) hide show
  1. package/LICENSE +2 -2
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/analyzer.js +21 -1
  4. package/dist/builtins.d.ts.map +1 -1
  5. package/dist/builtins.js +14 -0
  6. package/dist/dependency-graph.d.ts.map +1 -1
  7. package/dist/dependency-graph.js +27 -13
  8. package/dist/manifest-loader.d.ts +23 -1
  9. package/dist/manifest-loader.d.ts.map +1 -1
  10. package/dist/manifest-loader.js +66 -3
  11. package/dist/position-metadata.d.ts +6 -1
  12. package/dist/position-metadata.d.ts.map +1 -1
  13. package/dist/position-metadata.js +10 -2
  14. package/dist/precompile.d.ts.map +1 -1
  15. package/dist/precompile.js +9 -1
  16. package/dist/reference-field-map.js +58 -6
  17. package/dist/resolve-ref-sentinels.d.ts +27 -0
  18. package/dist/resolve-ref-sentinels.d.ts.map +1 -0
  19. package/dist/resolve-ref-sentinels.js +114 -0
  20. package/dist/schema-compat.d.ts +7 -1
  21. package/dist/schema-compat.d.ts.map +1 -1
  22. package/dist/schema-compat.js +19 -2
  23. package/dist/system-kinds.d.ts +25 -0
  24. package/dist/system-kinds.d.ts.map +1 -0
  25. package/dist/system-kinds.js +34 -0
  26. package/dist/types.d.ts +12 -0
  27. package/dist/types.d.ts.map +1 -1
  28. package/dist/validate-references.d.ts.map +1 -1
  29. package/dist/validate-references.js +32 -6
  30. package/package.json +4 -3
  31. package/src/analyzer.ts +23 -1
  32. package/src/builtins.ts +14 -0
  33. package/src/dependency-graph.ts +27 -14
  34. package/src/manifest-loader.ts +69 -4
  35. package/src/position-metadata.ts +10 -2
  36. package/src/precompile.ts +8 -1
  37. package/src/reference-field-map.ts +83 -6
  38. package/src/resolve-ref-sentinels.ts +127 -0
  39. package/src/schema-compat.ts +19 -2
  40. package/src/system-kinds.ts +37 -0
  41. package/src/types.ts +12 -0
  42. package/src/validate-references.ts +34 -6
@@ -142,7 +142,7 @@ export function buildReferenceFieldMap(schema: Record<string, any>): ReferenceFi
142
142
  const map: ReferenceFieldMap = new Map();
143
143
  if (schema.properties) {
144
144
  for (const [key, propSchema] of Object.entries(schema.properties)) {
145
- traverseNode(propSchema as Record<string, any>, key, map);
145
+ traverseNode(propSchema as Record<string, any>, key, map, schema);
146
146
  }
147
147
  }
148
148
  return map;
@@ -172,11 +172,29 @@ export function buildFieldMapAtPath(
172
172
  pathPrefix: string,
173
173
  ): ReferenceFieldMap {
174
174
  const map: ReferenceFieldMap = new Map();
175
- traverseNode(schema, pathPrefix, map);
175
+ traverseNode(schema, pathPrefix, map, schema);
176
176
  return map;
177
177
  }
178
178
 
179
- function traverseNode(node: Record<string, any>, path: string, map: ReferenceFieldMap): void {
179
+ function traverseNode(
180
+ node: Record<string, any>,
181
+ path: string,
182
+ map: ReferenceFieldMap,
183
+ root?: Record<string, any>,
184
+ visitedRefs: Set<string> = new Set(),
185
+ ): void {
186
+ // Local `$ref` is intentionally NOT followed. Descending into shared
187
+ // `$defs` (notably `Run.Sequence`'s `step` definition) would surface
188
+ // ref slots like `steps[].invoke` that Phase 5 then injects live
189
+ // instances into; today's `Run.Sequence` controller calls
190
+ // `instance.invoke()` directly when handed an instance, bypassing
191
+ // the kernel's `runInvoke` emit-Invoked path. The walker fix and the
192
+ // dispatcher fix need to land together — see the follow-up in
193
+ // [kernel/nodejs/plans/reference-syntax-unification.md] and the
194
+ // stopgap in `resource-context.ts:resolveChildren`. `visitedRefs`
195
+ // stays as a parameter so the recursive calls below thread the right
196
+ // signature; turning the descent back on is a single-branch change.
197
+ if (typeof node?.$ref === "string") return;
180
198
  // Scope slot — record and stop; do not recurse into scope contents
181
199
  if ("x-telo-scope" in node) {
182
200
  map.set(path, { scope: node["x-telo-scope"] });
@@ -200,13 +218,32 @@ function traverseNode(node: Record<string, any>, path: string, map: ReferenceFie
200
218
 
201
219
  // Array — recurse into items
202
220
  if (node.type === "array" && node.items) {
203
- traverseNode(node.items as Record<string, any>, path + "[]", map);
221
+ traverseNode(node.items as Record<string, any>, path + "[]", map, root, visitedRefs);
204
222
  }
205
223
 
206
224
  // Object — recurse into properties
207
225
  if (node.properties) {
208
226
  for (const [key, propSchema] of Object.entries(node.properties)) {
209
- traverseNode(propSchema as Record<string, any>, `${path}.${key}`, map);
227
+ traverseNode(propSchema as Record<string, any>, `${path}.${key}`, map, root, visitedRefs);
228
+ }
229
+ }
230
+
231
+ // Variant branches — descend into every alternative's properties / items.
232
+ // Schemas that discriminate on shape (Run.Sequence's step kinds:
233
+ // `oneOf: [{properties: {invoke}}, {properties: {try}}, ...]`) hide ref
234
+ // slots inside the branch. Walking each branch surfaces those slots into
235
+ // the field map so downstream passes (ref validation, sentinel
236
+ // resolution, dependency graph) cover them without a runtime fallback.
237
+ // The same field path may be added by multiple branches; the later
238
+ // assignment wins, which is fine — branches with the same field path
239
+ // share the same ref/context configuration (any divergence is already
240
+ // a schema bug).
241
+ for (const variantKey of ["oneOf", "anyOf", "allOf"] as const) {
242
+ const variants = node[variantKey];
243
+ if (!Array.isArray(variants)) continue;
244
+ for (const variant of variants) {
245
+ if (!variant || typeof variant !== "object") continue;
246
+ traverseVariant(variant as Record<string, any>, path, map, root, visitedRefs);
210
247
  }
211
248
  }
212
249
 
@@ -218,6 +255,46 @@ function traverseNode(node: Record<string, any>, path: string, map: ReferenceFie
218
255
  typeof node.additionalProperties === "object" &&
219
256
  !Array.isArray(node.additionalProperties)
220
257
  ) {
221
- traverseNode(node.additionalProperties as Record<string, any>, `${path}.{}`, map);
258
+ traverseNode(
259
+ node.additionalProperties as Record<string, any>,
260
+ `${path}.{}`,
261
+ map,
262
+ root,
263
+ visitedRefs,
264
+ );
265
+ }
266
+ }
267
+
268
+ /** Walk a single variant of a `oneOf` / `anyOf` / `allOf` branch. Only
269
+ * the properties / items / map slots are followed — collectRefs at the
270
+ * variant root is handled by the parent's `collectRefs(node)` already
271
+ * (anyOf of x-telo-ref branches is the canonical multi-ref shape). */
272
+ function traverseVariant(
273
+ variant: Record<string, any>,
274
+ path: string,
275
+ map: ReferenceFieldMap,
276
+ root?: Record<string, any>,
277
+ visitedRefs: Set<string> = new Set(),
278
+ ): void {
279
+ if (variant.properties) {
280
+ for (const [key, propSchema] of Object.entries(variant.properties)) {
281
+ traverseNode(propSchema as Record<string, any>, `${path}.${key}`, map, root, visitedRefs);
282
+ }
283
+ }
284
+ if (variant.type === "array" && variant.items) {
285
+ traverseNode(variant.items as Record<string, any>, path + "[]", map, root, visitedRefs);
286
+ }
287
+ if (
288
+ variant.additionalProperties &&
289
+ typeof variant.additionalProperties === "object" &&
290
+ !Array.isArray(variant.additionalProperties)
291
+ ) {
292
+ traverseNode(
293
+ variant.additionalProperties as Record<string, any>,
294
+ `${path}.{}`,
295
+ map,
296
+ root,
297
+ visitedRefs,
298
+ );
222
299
  }
223
300
  }
@@ -0,0 +1,127 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import { isRefSentinel } from "@telorun/templating";
3
+ import type { AliasResolver } from "./alias-resolver.js";
4
+ import type { DefinitionRegistry } from "./definition-registry.js";
5
+ import { isRefEntry } from "./reference-field-map.js";
6
+ import { REF_RESOLUTION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
7
+
8
+ /**
9
+ * Walks every `x-telo-ref` slot in every non-system resource and rewrites
10
+ * `!ref <name>` sentinels in-place to `{kind: <resolved-kind>, name}`.
11
+ *
12
+ * The downstream pipeline (inline normalization, dependency graph, kernel
13
+ * controllers) expects every ref-slot value to be either a `{kind, name}`
14
+ * object, an inline-definition object, or a legacy bare string — resolving
15
+ * sentinels here keeps that contract intact so each consumer doesn't need
16
+ * its own sentinel branch.
17
+ *
18
+ * The walker assigns `kind` by name lookup (resource names are unique
19
+ * within a manifest scope). When the name doesn't resolve in the local
20
+ * `byName` map, the sentinel is left in place so `validateReferences`
21
+ * can emit the `UNRESOLVED_REFERENCE` diagnostic with full context.
22
+ *
23
+ * Mutation strategy: the field-path walker descends the resource tree
24
+ * directly and replaces the sentinel on its parent container. Re-parsing
25
+ * a string-encoded concrete path (the earlier shape) coupled the writer
26
+ * to the path-encoding rules of `resolveFieldEntries` — any new path
27
+ * marker would silently break this writer. Descending directly avoids
28
+ * that coupling.
29
+ */
30
+ export function resolveRefSentinels(
31
+ resources: ResourceManifest[],
32
+ registry: DefinitionRegistry,
33
+ aliases?: AliasResolver,
34
+ aliasesByModule?: Map<string, AliasResolver>,
35
+ ): void {
36
+ const byName = new Map<string, ResourceManifest>();
37
+ for (const r of resources) {
38
+ if (r.metadata?.name && !SYSTEM_KINDS.has(r.kind)) {
39
+ byName.set(r.metadata.name as string, r);
40
+ }
41
+ }
42
+
43
+ for (const r of resources) {
44
+ if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
45
+
46
+ const fieldMap =
47
+ aliases && aliasesByModule
48
+ ? registry.expandedFieldMapForResource(r, aliases, aliasesByModule)
49
+ : registry.getFieldMapForKind(r.kind, aliases);
50
+ if (!fieldMap) continue;
51
+
52
+ for (const [fieldPath, entry] of fieldMap) {
53
+ if (!isRefEntry(entry)) continue;
54
+ replaceSentinelsAtPath(r as Record<string, unknown>, fieldPath, byName);
55
+ }
56
+ }
57
+ }
58
+
59
+ /** Walks `obj` along `fieldPath` (dot notation with `[]` for arrays and
60
+ * `{}` for additionalProperties-typed maps) and replaces any `!ref`
61
+ * sentinel value at the terminal slot with `{kind, name}` looked up
62
+ * via `byName`. Mutates the parent container in place; no string-path
63
+ * round-trip. */
64
+ function replaceSentinelsAtPath(
65
+ obj: Record<string, unknown>,
66
+ fieldPath: string,
67
+ byName: Map<string, ResourceManifest>,
68
+ ): void {
69
+ const parts = fieldPath.split(".");
70
+ descend(obj, parts, byName);
71
+ }
72
+
73
+ function descend(
74
+ obj: unknown,
75
+ parts: string[],
76
+ byName: Map<string, ResourceManifest>,
77
+ ): void {
78
+ if (obj == null || typeof obj !== "object" || parts.length === 0) return;
79
+ const [head, ...rest] = parts;
80
+
81
+ // Map iteration: descend into every value of the current object.
82
+ if (head === "{}") {
83
+ const container = obj as Record<string, unknown>;
84
+ for (const key of Object.keys(container)) {
85
+ const child = container[key];
86
+ if (rest.length === 0) {
87
+ if (isRefSentinel(child)) {
88
+ const target = byName.get(child.source);
89
+ if (target) container[key] = { kind: target.kind as string, name: child.source };
90
+ }
91
+ } else {
92
+ descend(child, rest, byName);
93
+ }
94
+ }
95
+ return;
96
+ }
97
+
98
+ const isArr = head.endsWith("[]");
99
+ const key = isArr ? head.slice(0, -2) : head;
100
+ const container = obj as Record<string, unknown>;
101
+ const val = container[key];
102
+ if (val == null) return;
103
+
104
+ if (isArr) {
105
+ if (!Array.isArray(val)) return;
106
+ for (let i = 0; i < val.length; i++) {
107
+ if (rest.length === 0) {
108
+ const elem = val[i];
109
+ if (isRefSentinel(elem)) {
110
+ const target = byName.get(elem.source);
111
+ if (target) val[i] = { kind: target.kind as string, name: elem.source };
112
+ }
113
+ } else {
114
+ descend(val[i], rest, byName);
115
+ }
116
+ }
117
+ } else {
118
+ if (rest.length === 0) {
119
+ if (isRefSentinel(val)) {
120
+ const target = byName.get(val.source);
121
+ if (target) container[key] = { kind: target.kind as string, name: val.source };
122
+ }
123
+ } else {
124
+ descend(val, rest, byName);
125
+ }
126
+ }
127
+ }
@@ -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
@@ -1,17 +1,13 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
+ import { isRefSentinel } from "@telorun/templating";
2
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`.
@@ -145,6 +141,38 @@ export function validateReferences(
145
141
  for (const { value: val, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
146
142
  if (!val) continue;
147
143
 
144
+ // `!ref <name>` sentinel — bare resource name marked at parse time as a
145
+ // reference. Look it up against the slot's x-telo-ref constraint exactly
146
+ // like the legacy bare-string path; the only difference is the value's
147
+ // shape (a TaggedSentinel rather than a raw string), which removed the
148
+ // string/inline ambiguity at the source.
149
+ if (isRefSentinel(val)) {
150
+ const refName = val.source;
151
+ const target =
152
+ byName.get(refName) ?? visibleScopeManifests.find((m) => m.metadata?.name === refName);
153
+ if (!target) {
154
+ diagnostics.push({
155
+ severity: DiagnosticSeverity.Error,
156
+ code: "UNRESOLVED_REFERENCE",
157
+ source: SOURCE,
158
+ message: `${resourceLabel}: reference at '${concretePath}' → resource '${refName}' not found`,
159
+ data: { resource: resourceData, filePath, path: concretePath },
160
+ });
161
+ continue;
162
+ }
163
+ const kindErrors = checkKind(target.kind as string, entry, registry, aliases);
164
+ if (kindErrors.length > 0) {
165
+ diagnostics.push({
166
+ severity: DiagnosticSeverity.Error,
167
+ code: "REFERENCE_KIND_MISMATCH",
168
+ source: SOURCE,
169
+ message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
170
+ data: { resource: resourceData, filePath, path: concretePath },
171
+ });
172
+ }
173
+ continue;
174
+ }
175
+
148
176
  // Name-only reference (plain string) — look up by name to validate.
149
177
  // Qualified references use "Kind.Name" format (e.g. "Http.Api.PaymentApi");
150
178
  // extract the resource name from the last dot segment.