@telorun/analyzer 0.12.0 → 0.13.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 (67) hide show
  1. package/README.md +2 -2
  2. package/dist/analysis-registry.d.ts +12 -0
  3. package/dist/analysis-registry.d.ts.map +1 -1
  4. package/dist/analysis-registry.js +15 -0
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/analyzer.js +131 -85
  7. package/dist/builtins.d.ts.map +1 -1
  8. package/dist/builtins.js +25 -0
  9. package/dist/cel-environment.d.ts +1 -1
  10. package/dist/cel-environment.d.ts.map +1 -1
  11. package/dist/cel-environment.js +40 -2
  12. package/dist/definition-registry.d.ts +12 -1
  13. package/dist/definition-registry.d.ts.map +1 -1
  14. package/dist/definition-registry.js +20 -1
  15. package/dist/dependency-graph.d.ts.map +1 -1
  16. package/dist/dependency-graph.js +41 -62
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/kernel-globals.d.ts +1 -1
  21. package/dist/kernel-globals.d.ts.map +1 -1
  22. package/dist/kernel-globals.js +19 -1
  23. package/dist/manifest-visitor.d.ts +109 -0
  24. package/dist/manifest-visitor.d.ts.map +1 -0
  25. package/dist/manifest-visitor.js +110 -0
  26. package/dist/reference-field-map.d.ts +1 -0
  27. package/dist/reference-field-map.d.ts.map +1 -1
  28. package/dist/reference-field-map.js +1 -1
  29. package/dist/schema-compat.d.ts +14 -0
  30. package/dist/schema-compat.d.ts.map +1 -1
  31. package/dist/schema-compat.js +38 -2
  32. package/dist/validate-cel-context.d.ts +14 -0
  33. package/dist/validate-cel-context.d.ts.map +1 -1
  34. package/dist/validate-cel-context.js +38 -0
  35. package/dist/validate-nested-inline.d.ts +30 -0
  36. package/dist/validate-nested-inline.d.ts.map +1 -0
  37. package/dist/validate-nested-inline.js +129 -0
  38. package/dist/validate-references.d.ts.map +1 -1
  39. package/dist/validate-references.js +117 -160
  40. package/dist/validate-unused-declarations.d.ts +25 -0
  41. package/dist/validate-unused-declarations.d.ts.map +1 -0
  42. package/dist/validate-unused-declarations.js +91 -0
  43. package/package.json +2 -2
  44. package/src/analysis-registry.ts +20 -0
  45. package/src/analyzer.ts +217 -158
  46. package/src/builtins.ts +25 -0
  47. package/src/cel-environment.ts +42 -1
  48. package/src/definition-registry.ts +20 -1
  49. package/src/dependency-graph.ts +37 -52
  50. package/src/index.ts +11 -0
  51. package/src/kernel-globals.ts +22 -1
  52. package/src/manifest-visitor.ts +251 -0
  53. package/src/reference-field-map.ts +1 -1
  54. package/src/schema-compat.ts +38 -2
  55. package/src/validate-cel-context.ts +50 -0
  56. package/src/validate-nested-inline.ts +158 -0
  57. package/src/validate-references.ts +168 -211
  58. package/src/validate-unused-declarations.ts +95 -0
  59. package/dist/adapters/http-adapter.d.ts +0 -10
  60. package/dist/adapters/http-adapter.d.ts.map +0 -1
  61. package/dist/adapters/http-adapter.js +0 -18
  62. package/dist/adapters/node-adapter.d.ts +0 -17
  63. package/dist/adapters/node-adapter.d.ts.map +0 -1
  64. package/dist/adapters/node-adapter.js +0 -71
  65. package/dist/adapters/registry-adapter.d.ts +0 -15
  66. package/dist/adapters/registry-adapter.d.ts.map +0 -1
  67. package/dist/adapters/registry-adapter.js +0 -53
@@ -1,6 +1,13 @@
1
1
  import { Environment } from "@marcbachmann/cel-js";
2
2
  import type { ResourceManifest } from "@telorun/sdk";
3
- import { jsonSchemaToCelType } from "./schema-compat.js";
3
+ import { jsonSchemaToCelType, VALUE_BRAND_BASE } from "./schema-compat.js";
4
+
5
+ /** Transport protocol on a `ports` entry → the nominal CEL brand its resolved
6
+ * value carries. Mirrors the `protocol` enum in the Application schema. */
7
+ const PORT_PROTOCOL_BRAND: Record<string, string> = {
8
+ tcp: "TcpPort",
9
+ udp: "UdpPort",
10
+ };
4
11
 
5
12
  export { buildCelEnvironment } from "@telorun/templating";
6
13
  export type { CelHandlers } from "@telorun/templating";
@@ -19,10 +26,23 @@ export function buildTypedCelEnvironment(
19
26
  baseEnv: Environment,
20
27
  manifest: ResourceManifest,
21
28
  extraContextSchema?: Record<string, any> | null,
29
+ // The `ports` namespace is Application-only and lives on the module doc, not
30
+ // on the resource being analyzed. When validating a resource, the caller
31
+ // passes the module manifest here so `${{ ports.X }}` types cross-doc.
32
+ rootModuleManifest?: ResourceManifest,
22
33
  ): Environment {
23
34
  try {
24
35
  const env = baseEnv.clone();
25
36
 
37
+ // Register nominal value brands (TcpPort/UdpPort/…) on the *clone* so the
38
+ // type-checker can distinguish structurally-identical values. The base env
39
+ // (shared with the kernel runtime) is untouched — a branded value flows as
40
+ // a plain integer at runtime, so only static checking needs these. cel-js
41
+ // auto-generates a field-less wrapper class; no runtime constructor needed.
42
+ for (const brand of Object.keys(VALUE_BRAND_BASE)) {
43
+ (env as any).registerType(brand, { fields: {} });
44
+ }
45
+
26
46
  // Build typed ObjectSchema from manifest.variables if it looks like a schema map
27
47
  const vars = (manifest as Record<string, unknown>).variables;
28
48
  if (vars !== null && typeof vars === "object" && !Array.isArray(vars)) {
@@ -42,6 +62,27 @@ export function buildTypedCelEnvironment(
42
62
  env.registerVariable("variables", "map");
43
63
  }
44
64
 
65
+ // `ports` namespace: each entry types as the brand its `protocol` selects
66
+ // (tcp → TcpPort, udp → UdpPort), so `${{ ports.http }}` carries a nominal
67
+ // type that consuming fields can check against.
68
+ const portsManifest = ((rootModuleManifest ?? manifest) as Record<string, unknown>).ports;
69
+ if (portsManifest !== null && typeof portsManifest === "object" && !Array.isArray(portsManifest)) {
70
+ const portEntries = Object.entries(portsManifest as Record<string, any>).filter(
71
+ ([, v]) => v !== null && typeof v === "object" && !Array.isArray(v),
72
+ );
73
+ if (portEntries.length > 0) {
74
+ const schema: Record<string, string> = {};
75
+ for (const [k, v] of portEntries) {
76
+ schema[k] = PORT_PROTOCOL_BRAND[(v as { protocol?: string }).protocol ?? "tcp"] ?? "int";
77
+ }
78
+ (env as any).registerVariable({ name: "ports", schema });
79
+ } else {
80
+ env.registerVariable("ports", "map");
81
+ }
82
+ } else {
83
+ env.registerVariable("ports", "map");
84
+ }
85
+
45
86
  env.registerVariable("secrets", "map");
46
87
  env.registerVariable("resources", "map");
47
88
  env.registerVariable("env", "map");
@@ -119,7 +119,10 @@ export class DefinitionRegistry {
119
119
  }
120
120
 
121
121
  /** Validates data against a schema using this registry's AJV instance, which has all
122
- * registered definition schemas loaded — enabling cross-module $ref resolution. */
122
+ * registered definition schemas loaded — enabling cross-module $ref resolution.
123
+ * A compile failure returns `[]` here; it is surfaced loudly (once, on the
124
+ * owning definition) by `schemaCompileError` via the analyzer's
125
+ * definition-schema compile check, so resources are never silently skipped. */
123
126
  validateWithRefs(data: unknown, schema: Record<string, any>): string[] {
124
127
  let validate: ReturnType<typeof this.ajv.compile>;
125
128
  try {
@@ -131,6 +134,22 @@ export class DefinitionRegistry {
131
134
  return (validate.errors ?? []).map(formatSingleError);
132
135
  }
133
136
 
137
+ /** Returns the AJV compile error for `schema`, or `undefined` when it compiles.
138
+ * Compiles on this registry's instance, which has every loaded module schema
139
+ * plus the manifest root registered, so local `#/$defs`, `telo://manifest`,
140
+ * and cross-module `$ref`s all resolve. Used to fail loud on a definition
141
+ * schema that AJV cannot compile — otherwise `validateAgainstSchema` /
142
+ * `validateWithRefs` would swallow the failure and silently skip every
143
+ * resource of that kind. */
144
+ schemaCompileError(schema: Record<string, any>): string | undefined {
145
+ try {
146
+ this.ajv.compile(schema);
147
+ return undefined;
148
+ } catch (err) {
149
+ return err instanceof Error ? err.message : String(err);
150
+ }
151
+ }
152
+
134
153
  private tryRegisterSchema(
135
154
  moduleName: string,
136
155
  typeName: string,
@@ -2,7 +2,7 @@ import type { ResourceManifest } from "@telorun/sdk";
2
2
  import { isRefSentinel } from "@telorun/templating";
3
3
  import type { AliasResolver } from "./alias-resolver.js";
4
4
  import type { DefinitionRegistry } from "./definition-registry.js";
5
- import { isRefEntry, isScopeEntry, resolveFieldValues } from "./reference-field-map.js";
5
+ import { visitManifest } from "./manifest-visitor.js";
6
6
  import { DEPENDENCY_GRAPH_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
7
7
 
8
8
  export interface ResourceNode {
@@ -57,64 +57,49 @@ export function buildDependencyGraph(
57
57
  const deps = new Map<string, Set<string>>();
58
58
  for (const key of nodes.keys()) deps.set(key, new Set());
59
59
 
60
- for (const r of resources) {
61
- if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
62
-
63
- const sourceKey = nodeKey(r.kind, r.metadata.name as string);
64
- // Use the expanded map so refs nested behind x-telo-schema-from contribute
65
- // edges to the DAG. Without these, a parent (e.g. Http.Server) can init
66
- // before its extracted encoder and Phase 5 injection fires against a
67
- // not-yet-created dependency.
68
- const fieldMap =
69
- aliases && aliasesByModule
70
- ? registry.expandedFieldMapForResource(r, aliases, aliasesByModule)
71
- : registry.getFieldMapForKind(r.kind, aliases);
72
- if (!fieldMap) continue;
73
-
74
- // Collect names of resources declared inside scope fields — these are initialized
75
- // on-demand at runtime, not at boot, so edges pointing to them are excluded from the DAG.
76
- const scopedNames = new Set<string>();
77
- for (const [scopeFieldPath, entry] of fieldMap) {
78
- if (!isScopeEntry(entry)) continue;
79
- const scopeVal = (r as Record<string, unknown>)[scopeFieldPath];
80
- if (!Array.isArray(scopeVal)) continue;
81
- for (const item of scopeVal) {
82
- const name = (item as any)?.metadata?.name;
83
- if (typeof name === "string") scopedNames.add(name);
84
- }
85
- }
86
-
87
- for (const [fieldPath, entry] of fieldMap) {
88
- if (!isRefEntry(entry)) continue;
89
-
90
- for (const val of resolveFieldValues(r, fieldPath)) {
91
- if (!val) continue;
92
-
93
- // `!ref <name>` sentinel — look up the target's kind from the
94
- // name (resources are unique by name) so the edge carries the
95
- // concrete kind, matching the {kind, name} edge shape below.
60
+ // Names of resources declared inside the *current* resource's scope fields —
61
+ // initialized on-demand at runtime, not at boot, so edges pointing to them
62
+ // are excluded. Scoping is per-source-resource: an edge A → B is dropped only
63
+ // when B is declared inside A's own scope (the visitor's ScopeBoundary fires
64
+ // before that resource's RefSites, so this is set before any edge is added).
65
+ let scopedNames = new Set<string>();
66
+
67
+ // Expanded map so refs nested behind x-telo-schema-from contribute edges to
68
+ // the DAG. Without these, a parent (e.g. Http.Server) can init before its
69
+ // extracted encoder and Phase 5 injection fires against a not-yet-created
70
+ // dependency.
71
+ visitManifest(
72
+ resources,
73
+ registry,
74
+ {
75
+ onScope: (e) => {
76
+ scopedNames = e.enclosedNames;
77
+ },
78
+ onRef: (e) => {
79
+ const sourceKey = nodeKey(e.source.kind, e.source.metadata!.name as string);
80
+ const val = e.value;
81
+
82
+ // `!ref <name>` sentinel look up the target's kind from the name
83
+ // (resources are unique by name) so the edge carries the concrete kind,
84
+ // matching the {kind, name} edge shape below.
96
85
  if (isRefSentinel(val)) {
97
86
  const refName = val.source;
98
- if (scopedNames.has(refName)) continue;
87
+ if (scopedNames.has(refName)) return;
99
88
  const node = nodesByName.get(refName);
100
- if (node) {
101
- deps.get(sourceKey)!.add(nodeKey(node.kind, node.name));
102
- }
103
- continue;
89
+ if (node) deps.get(sourceKey)!.add(nodeKey(node.kind, node.name));
90
+ return;
104
91
  }
105
92
 
106
- if (typeof val !== "object") continue;
93
+ if (typeof val !== "object") return;
107
94
  const ref = val as Record<string, unknown>;
108
- if (!ref.kind || !ref.name) continue;
109
- // Edges to scoped resources are runtime deps, not boot-time deps — exclude from DAG
110
- if (scopedNames.has(ref.name as string)) continue;
95
+ if (!ref.kind || !ref.name) return;
96
+ if (scopedNames.has(ref.name as string)) return;
111
97
  const targetKey = nodeKey(ref.kind as string, ref.name as string);
112
- if (nodes.has(targetKey)) {
113
- deps.get(sourceKey)!.add(targetKey);
114
- }
115
- }
116
- }
117
- }
98
+ if (nodes.has(targetKey)) deps.get(sourceKey)!.add(targetKey);
99
+ },
100
+ },
101
+ { aliases, aliasesByModule, skipKinds: SYSTEM_KINDS, expand: true },
102
+ );
118
103
 
119
104
  // --- Kahn's topological sort ---
120
105
  // in-degree[X] = number of X's dependencies (size of deps[X])
package/src/index.ts CHANGED
@@ -9,6 +9,17 @@ export type {
9
9
  ParseError,
10
10
  } from "./loaded-types.js";
11
11
  export { flattenForAnalyzer, flattenLoadedModule } from "./flatten-for-analyzer.js";
12
+ export { visitManifest } from "./manifest-visitor.js";
13
+ export type {
14
+ CelSiteEvent,
15
+ ManifestVisitor,
16
+ RefSiteEvent,
17
+ ResourceEnterEvent,
18
+ ResourceExitEvent,
19
+ ScopeBoundaryEvent,
20
+ SchemaFromSiteEvent,
21
+ VisitOptions,
22
+ } from "./manifest-visitor.js";
12
23
  export { Loader } from "./manifest-loader.js";
13
24
  export { isModuleKind, MODULE_KINDS } from "./module-kinds.js";
14
25
  export type { ModuleKind } from "./module-kinds.js";
@@ -12,7 +12,7 @@ import { residualEntrySchemaMap } from "./residual-schema.js";
12
12
  * There is no `imports` namespace at runtime — import snapshots are stored
13
13
  * under `resources.<alias>`.
14
14
  */
15
- export const KERNEL_GLOBAL_NAMES = ["variables", "secrets", "resources", "env"] as const;
15
+ export const KERNEL_GLOBAL_NAMES = ["variables", "secrets", "resources", "ports", "env"] as const;
16
16
 
17
17
  const SYSTEM_KINDS = new Set([
18
18
  "Telo.Definition",
@@ -67,11 +67,32 @@ export function buildKernelGlobalsSchema(
67
67
  properties: resourceProps,
68
68
  additionalProperties: false,
69
69
  },
70
+ ports: buildPortsSchema(moduleManifest?.ports),
70
71
  env: { type: "object", additionalProperties: true },
71
72
  },
72
73
  };
73
74
  }
74
75
 
76
+ /** Build the closed `ports` chain-access schema: each declared port is an
77
+ * integer, so `ports.<name>` resolves and `ports.typo` (or member access past
78
+ * a port, like `ports.http.foo`) is flagged. Falls back to an open map when
79
+ * the module declares no ports. */
80
+ function buildPortsSchema(
81
+ ports: Record<string, any> | null | undefined,
82
+ ): Record<string, any> {
83
+ if (!ports || typeof ports !== "object" || Array.isArray(ports)) {
84
+ return { type: "object", additionalProperties: true };
85
+ }
86
+ const props: Record<string, any> = {};
87
+ for (const name of Object.keys(ports)) {
88
+ props[name] = { type: "integer" };
89
+ }
90
+ if (Object.keys(props).length === 0) {
91
+ return { type: "object", additionalProperties: true };
92
+ }
93
+ return { type: "object", properties: props, additionalProperties: false };
94
+ }
95
+
75
96
  /** Wrap a JSON Schema property map (like `Telo.Application.variables`) into a
76
97
  * closed object schema suitable for chain-access validation. For Application
77
98
  * entries the per-entry shape carries kernel-specific keys (`env`, `default`)
@@ -0,0 +1,251 @@
1
+ import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
+ import { walkCelExpressions } from "@telorun/templating";
3
+ import type { AliasResolver } from "./alias-resolver.js";
4
+ import type { DefinitionRegistry } from "./definition-registry.js";
5
+ import {
6
+ isRefEntry,
7
+ isSchemaFromEntry,
8
+ isScopeEntry,
9
+ resolveFieldEntries,
10
+ resolveFieldValues,
11
+ type RefFieldEntry,
12
+ type SchemaFromFieldEntry,
13
+ } from "./reference-field-map.js";
14
+ import { extractContextsFromSchema, pathMatchesScope } from "./validate-cel-context.js";
15
+
16
+ /**
17
+ * One descent surface over a manifest's resources, emitting the annotation
18
+ * sites every analyzer pass needs. It replaces the iteration scaffolding that
19
+ * `validate-references`, `dependency-graph`, and the analyzer's CEL walk each
20
+ * reimplemented (field-map fetch, scope collection, ref/schema-from iteration,
21
+ * CEL expression walk + context matching).
22
+ *
23
+ * Two discovery mechanics ride one per-resource pass:
24
+ *
25
+ * - **Path-driven** — ref / scope / schema-from sites come from the resource's
26
+ * per-kind field map (`RefSite`, `ScopeBoundary`, `SchemaFromSite`). This is
27
+ * map iteration resolved against the resource value, not a node-by-node tree
28
+ * descent; the field map already unifies all three annotation types.
29
+ * - **Value-tree-driven** — compiled `${{...}}` / `!cel` nodes are found by
30
+ * scanning the resource value tree (`CelSite`). CEL can sit in any string
31
+ * field, including ones the field map never lists, so its discovery is
32
+ * fundamentally not path-driven; the field map only supplies the matched
33
+ * `x-telo-context` schema at the enclosing path.
34
+ *
35
+ * Handlers are optional (Babel-style): the walker computes and emits only what
36
+ * the visitor subscribes to, and skips the work behind absent handlers.
37
+ *
38
+ * **Scope is per-resource.** `ScopeBoundary` is emitted once per resource at
39
+ * enter time, before that resource's `RefSite`s, carrying both the source
40
+ * enclosure prefixes (for refs written *inside* a scope) and the enclosed
41
+ * resource-name set (for consumers that drop edges to scoped targets). No
42
+ * cross-resource ordering or global enclosed-name union is implied — every
43
+ * consumer's scope decision is local to the resource being visited, matching
44
+ * the semantics each pass had before this walker existed.
45
+ */
46
+
47
+ export interface ResourceEnterEvent {
48
+ source: ResourceManifest;
49
+ /** Resolved definition for the resource's kind, or undefined when unknown. */
50
+ definition?: ResourceDefinition;
51
+ }
52
+
53
+ export interface ResourceExitEvent {
54
+ source: ResourceManifest;
55
+ }
56
+
57
+ export interface ScopeBoundaryEvent {
58
+ source: ResourceManifest;
59
+ /** Dot-form prefixes of every `x-telo-scope` field on this resource. */
60
+ scopePrefixes: string[];
61
+ /** Scope-field JSON Pointer → manifests declared within that scope. */
62
+ manifestsByPointer: Map<string, ResourceManifest[]>;
63
+ /** Names of every resource declared inside this resource's scopes. Used by
64
+ * the dependency graph to drop boot edges to scoped (on-demand) targets. */
65
+ enclosedNames: Set<string>;
66
+ }
67
+
68
+ export interface RefSiteEvent {
69
+ source: ResourceManifest;
70
+ /** Field-map path with `[]` / `{}` markers (e.g. `steps[].invoke`). */
71
+ fieldPath: string;
72
+ /** Concrete path with `[N]` / map keys, matching `buildPositionIndex` keys. */
73
+ concretePath: string;
74
+ /** The ref value at this concrete site (sentinel, string, or `{kind,name}`). */
75
+ value: unknown;
76
+ /** The ref constraint (`refs[]`, `isArray`, optional `context`). */
77
+ entry: RefFieldEntry;
78
+ /** True when `fieldPath` falls within one of this resource's scope prefixes —
79
+ * source enclosure, used to scope a ref's candidate set. */
80
+ inScope: boolean;
81
+ /** Scope manifests visible to this ref path (non-empty only when `inScope`). */
82
+ visibleScopeManifests: ResourceManifest[];
83
+ }
84
+
85
+ export interface SchemaFromSiteEvent {
86
+ source: ResourceManifest;
87
+ /** Field-map path of the `x-telo-schema-from` slot. */
88
+ fieldPath: string;
89
+ entry: SchemaFromFieldEntry;
90
+ }
91
+
92
+ export interface CelSiteEvent {
93
+ source: ResourceManifest;
94
+ /** Concrete dotted path of the expression (from `walkCelExpressions`). */
95
+ path: string;
96
+ /** The CEL source expression. */
97
+ expr: string;
98
+ /** Engine that owns the expression (`cel`, `literal`, …). */
99
+ engineName: string;
100
+ /** Raw `x-telo-context` schema matched at the enclosing path, if any. The
101
+ * consumer resolves `x-telo-context-*` annotations and merges its own
102
+ * globals — the walker only does the path → context match. */
103
+ contextSchema?: Record<string, any>;
104
+ /** Scope of the matched context (e.g. `$.routes[*].handler`), if matched. */
105
+ matchedScope?: string;
106
+ }
107
+
108
+ export interface ManifestVisitor {
109
+ onResourceEnter?(e: ResourceEnterEvent): void;
110
+ onScope?(e: ScopeBoundaryEvent): void;
111
+ onRef?(e: RefSiteEvent): void;
112
+ onSchemaFrom?(e: SchemaFromSiteEvent): void;
113
+ onCel?(e: CelSiteEvent): void;
114
+ onResourceExit?(e: ResourceExitEvent): void;
115
+ }
116
+
117
+ export interface VisitOptions {
118
+ aliases?: AliasResolver;
119
+ aliasesByModule?: Map<string, AliasResolver>;
120
+ /** Resource kinds to skip entirely (kind blueprints, import metadata, …). */
121
+ skipKinds?: ReadonlySet<string>;
122
+ /** When true, ref / scope sites come from the schema-from-expanded field map
123
+ * so refs nested behind `x-telo-schema-from` are surfaced. `SchemaFromSite`
124
+ * events are always emitted from the base map regardless of this flag. */
125
+ expand?: boolean;
126
+ }
127
+
128
+ const scopePrefixOf = (pointer: string): string =>
129
+ pointer.replace(/^\//, "").replace(/\//g, ".");
130
+
131
+ const pathUnderPrefix = (fieldPath: string, prefix: string): boolean =>
132
+ fieldPath === prefix ||
133
+ fieldPath.startsWith(prefix + ".") ||
134
+ fieldPath.startsWith(prefix + "[");
135
+
136
+ export function visitManifest(
137
+ resources: ResourceManifest[],
138
+ registry: DefinitionRegistry,
139
+ visitor: ManifestVisitor,
140
+ options: VisitOptions = {},
141
+ ): void {
142
+ const { aliases, aliasesByModule, skipKinds, expand } = options;
143
+
144
+ const wantsRefs = !!visitor.onRef;
145
+ const wantsScope = !!visitor.onScope;
146
+ const wantsSchemaFrom = !!visitor.onSchemaFrom;
147
+ const wantsCel = !!visitor.onCel;
148
+
149
+ for (const r of resources) {
150
+ if (!r.metadata?.name || !r.kind) continue;
151
+ if (skipKinds?.has(r.kind)) continue;
152
+
153
+ const resolvedKind = aliases?.resolveKind(r.kind);
154
+ const definition =
155
+ registry.resolve(r.kind) ??
156
+ (resolvedKind ? registry.resolve(resolvedKind) : undefined);
157
+
158
+ visitor.onResourceEnter?.({ source: r, definition });
159
+
160
+ if (wantsRefs || wantsScope || wantsSchemaFrom) {
161
+ const baseMap = aliases
162
+ ? registry.getFieldMapForKind(r.kind, aliases)
163
+ : registry.getFieldMap(r.kind);
164
+
165
+ // Expanded map drives ref/scope sites when requested; schema-from sites
166
+ // come from the base map (expansion replaces them with nested refs).
167
+ const refScopeMap =
168
+ expand && aliases && aliasesByModule
169
+ ? registry.expandedFieldMapForResource(r, aliases, aliasesByModule)
170
+ : baseMap;
171
+
172
+ if (refScopeMap && (wantsRefs || wantsScope)) {
173
+ const manifestsByPointer = new Map<string, ResourceManifest[]>();
174
+ for (const [fieldPath, entry] of refScopeMap) {
175
+ if (!isScopeEntry(entry)) continue;
176
+ const raw = resolveFieldValues(r, fieldPath)
177
+ .flatMap((v) => (Array.isArray(v) ? v : [v]))
178
+ .filter((v): v is ResourceManifest => !!v && typeof v === "object");
179
+ const pointers = Array.isArray(entry.scope) ? entry.scope : [entry.scope];
180
+ for (const pointer of pointers) manifestsByPointer.set(pointer, raw);
181
+ }
182
+ const scopePrefixes = Array.from(manifestsByPointer.keys()).map(scopePrefixOf);
183
+
184
+ if (wantsScope) {
185
+ const enclosedNames = new Set<string>();
186
+ for (const manifests of manifestsByPointer.values()) {
187
+ for (const m of manifests) {
188
+ const name = m.metadata?.name;
189
+ if (typeof name === "string") enclosedNames.add(name);
190
+ }
191
+ }
192
+ visitor.onScope!({ source: r, scopePrefixes, manifestsByPointer, enclosedNames });
193
+ }
194
+
195
+ if (wantsRefs) {
196
+ for (const [fieldPath, entry] of refScopeMap) {
197
+ if (!isRefEntry(entry)) continue;
198
+
199
+ const inScope = scopePrefixes.some((prefix) => pathUnderPrefix(fieldPath, prefix));
200
+ const visibleScopeManifests: ResourceManifest[] = [];
201
+ if (inScope) {
202
+ for (const [pointer, manifests] of manifestsByPointer) {
203
+ if (pathUnderPrefix(fieldPath, scopePrefixOf(pointer))) {
204
+ visibleScopeManifests.push(...manifests);
205
+ }
206
+ }
207
+ }
208
+
209
+ for (const { value, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
210
+ if (!value) continue;
211
+ visitor.onRef!({
212
+ source: r,
213
+ fieldPath,
214
+ concretePath,
215
+ value,
216
+ entry,
217
+ inScope,
218
+ visibleScopeManifests,
219
+ });
220
+ }
221
+ }
222
+ }
223
+ }
224
+
225
+ if (wantsSchemaFrom && baseMap) {
226
+ for (const [fieldPath, entry] of baseMap) {
227
+ if (!isSchemaFromEntry(entry)) continue;
228
+ visitor.onSchemaFrom!({ source: r, fieldPath, entry });
229
+ }
230
+ }
231
+ }
232
+
233
+ if (wantsCel) {
234
+ const contexts = definition?.schema ? extractContextsFromSchema(definition.schema) : [];
235
+ walkCelExpressions(r, "", (expr, path, engineName) => {
236
+ let contextSchema: Record<string, any> | undefined;
237
+ let matchedScope: string | undefined;
238
+ for (const ctx of contexts) {
239
+ if (pathMatchesScope(path, ctx.scope)) {
240
+ contextSchema = ctx.schema;
241
+ matchedScope = ctx.scope;
242
+ break;
243
+ }
244
+ }
245
+ visitor.onCel!({ source: r, path, expr, engineName, contextSchema, matchedScope });
246
+ });
247
+ }
248
+
249
+ visitor.onResourceExit?.({ source: r });
250
+ }
251
+ }
@@ -148,7 +148,7 @@ export function buildReferenceFieldMap(schema: Record<string, any>): ReferenceFi
148
148
  return map;
149
149
  }
150
150
 
151
- function collectRefs(node: Record<string, any>): string[] {
151
+ export function collectRefs(node: Record<string, any>): string[] {
152
152
  const refs: string[] = [];
153
153
  if (typeof node["x-telo-ref"] === "string") {
154
154
  refs.push(node["x-telo-ref"]);
@@ -146,6 +146,10 @@ export function validateAgainstSchema(data: unknown, schema: Record<string, any>
146
146
  validate = ajv.compile(schema);
147
147
  compiledSchemaValidators.set(schema, validate);
148
148
  } catch {
149
+ // A schema that won't compile is reported loudly (once, on the owning
150
+ // definition) by the analyzer's definition-schema compile check
151
+ // (`DefinitionRegistry.schemaCompileError`), so returning `[]` here does
152
+ // not silently accept resources of that kind.
149
153
  return [];
150
154
  }
151
155
  }
@@ -200,9 +204,29 @@ export function navigateSchemaToExprPath(
200
204
  return current;
201
205
  }
202
206
 
207
+ /**
208
+ * Recognized `x-telo-type` value brands and the CEL primitive each refines.
209
+ * A brand is a nominal type the analyzer registers (see cel-environment.ts) so
210
+ * structurally-identical values (a `TcpPort` and a `UdpPort` are both integers)
211
+ * stay distinct for static wiring checks. Brands carry no runtime effect — the
212
+ * value flows as its base type. Add new brands here (e.g. `Url: "string"`).
213
+ */
214
+ export const VALUE_BRAND_BASE: Record<string, string> = {
215
+ TcpPort: "int",
216
+ UdpPort: "int",
217
+ };
218
+
219
+ /** Read a recognized `x-telo-type` brand off a schema, or undefined. */
220
+ export function brandOfSchema(schema: Record<string, any> | undefined): string | undefined {
221
+ const brand = schema?.["x-telo-type"];
222
+ return typeof brand === "string" && brand in VALUE_BRAND_BASE ? brand : undefined;
223
+ }
224
+
203
225
  /** Map a JSON Schema type annotation to a CEL type string. */
204
226
  export function jsonSchemaToCelType(schema: Record<string, any> | undefined): string {
205
227
  if (!schema || typeof schema !== "object") return "dyn";
228
+ const brand = brandOfSchema(schema);
229
+ if (brand) return brand;
206
230
  if (schema.anyOf || schema.oneOf || schema.allOf) return "dyn";
207
231
  if (Array.isArray(schema.type)) return "dyn";
208
232
  switch (schema.type) {
@@ -229,6 +253,18 @@ export function jsonSchemaToCelType(schema: Record<string, any> | undefined): st
229
253
  /** Check whether a CEL return type is compatible with a JSON Schema type constraint. */
230
254
  export function celTypeSatisfiesJsonSchema(celType: string, schema: Record<string, any>): boolean {
231
255
  if (celType === "dyn") return true;
256
+ // Nominal value brands: when the expression's type is a recognized brand,
257
+ // a branded consuming field must match exactly (a UdpPort wired into a
258
+ // TcpPort-branded field is the error we want). An unbranded field accepts
259
+ // the brand as its base type — gradual typing, so a TcpPort flows freely
260
+ // into a plain integer field. (A plain integer into a branded field is also
261
+ // allowed: only a *conflicting* brand is rejected.)
262
+ const sourceBase = VALUE_BRAND_BASE[celType];
263
+ if (sourceBase) {
264
+ const fieldBrand = brandOfSchema(schema);
265
+ if (fieldBrand) return fieldBrand === celType;
266
+ celType = sourceBase;
267
+ }
232
268
  if (!schema.type && !schema.anyOf && !schema.oneOf && !schema.allOf) return true;
233
269
  if (schema.anyOf || schema.oneOf || schema.allOf) return true;
234
270
  const schemaTypes = Array.isArray(schema.type) ? schema.type : [schema.type];
@@ -273,7 +309,7 @@ export function celPlaceholderForSchema(schema: Record<string, any>): unknown {
273
309
  const CEL_PURE_RE = /^\s*\$\{\{[^}]*\}\}\s*$/;
274
310
 
275
311
  /** Resolve a `$ref` (only `#/$defs/...` form) against the root schema. */
276
- function resolveRef(schema: Record<string, any>, root: Record<string, any>): Record<string, any> {
312
+ export function resolveRef(schema: Record<string, any>, root: Record<string, any>): Record<string, any> {
277
313
  if (schema.$ref && typeof schema.$ref === "string" && schema.$ref.startsWith("#/$defs/")) {
278
314
  const defName = schema.$ref.slice("#/$defs/".length);
279
315
  const resolved = root.$defs?.[defName];
@@ -283,7 +319,7 @@ function resolveRef(schema: Record<string, any>, root: Record<string, any>): Rec
283
319
  }
284
320
 
285
321
  /** Collect property schemas from top-level `properties` and all `oneOf`/`anyOf` sub-schemas. */
286
- function collectProperties(schema: Record<string, any>): Record<string, any> {
322
+ export function collectProperties(schema: Record<string, any>): Record<string, any> {
287
323
  const props: Record<string, any> = { ...(schema.properties ?? {}) };
288
324
  for (const sub of schema.oneOf ?? schema.anyOf ?? []) {
289
325
  if (sub && typeof sub === "object" && sub.properties) {
@@ -266,3 +266,53 @@ function navigatePath(obj: unknown, segments: string[]): unknown {
266
266
  }
267
267
  return cur;
268
268
  }
269
+
270
+ /**
271
+ * Walk a JSON Schema tree and collect all `x-telo-context` annotations,
272
+ * returning them as `{ scope, schema }` pairs using JSONPath-style scopes —
273
+ * the same format the analyzer uses for CEL context validation.
274
+ *
275
+ * Result is sorted by scope specificity (longer scope first) so that the
276
+ * per-expression resolver's first-match-wins logic picks the most-specific
277
+ * context. Without this, a broader ancestor scope (e.g. `$.resources[*]`)
278
+ * could shadow a narrower descendant scope whose activation differs.
279
+ */
280
+ export function extractContextsFromSchema(
281
+ schema: Record<string, any>,
282
+ path = "$",
283
+ ): Array<{ scope: string; schema: Record<string, any> }> {
284
+ const all = collectContexts(schema, path);
285
+ return all.sort((a, b) => b.scope.length - a.scope.length);
286
+ }
287
+
288
+ function collectContexts(
289
+ schema: Record<string, any>,
290
+ path: string,
291
+ ): Array<{ scope: string; schema: Record<string, any> }> {
292
+ if (!schema || typeof schema !== "object") return [];
293
+ const results: Array<{ scope: string; schema: Record<string, any> }> = [];
294
+
295
+ if (schema["x-telo-context"]) {
296
+ results.push({ scope: path, schema: schema["x-telo-context"] });
297
+ }
298
+
299
+ if (schema.properties) {
300
+ for (const [key, value] of Object.entries(schema.properties as Record<string, any>)) {
301
+ results.push(...collectContexts(value, `${path}.${key}`));
302
+ }
303
+ }
304
+
305
+ if (schema.items && typeof schema.items === "object") {
306
+ results.push(...collectContexts(schema.items, `${path}[*]`));
307
+ }
308
+
309
+ for (const key of ["oneOf", "anyOf", "allOf"] as const) {
310
+ if (Array.isArray(schema[key])) {
311
+ for (const subschema of schema[key]) {
312
+ results.push(...collectContexts(subschema, path));
313
+ }
314
+ }
315
+ }
316
+
317
+ return results;
318
+ }