@telorun/analyzer 0.12.1 → 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 (45) hide show
  1. package/dist/analysis-registry.d.ts +12 -0
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +15 -0
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/analyzer.js +64 -84
  6. package/dist/builtins.d.ts.map +1 -1
  7. package/dist/builtins.js +25 -0
  8. package/dist/cel-environment.d.ts +1 -1
  9. package/dist/cel-environment.d.ts.map +1 -1
  10. package/dist/cel-environment.js +40 -2
  11. package/dist/dependency-graph.d.ts.map +1 -1
  12. package/dist/dependency-graph.js +41 -62
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -0
  16. package/dist/kernel-globals.d.ts +1 -1
  17. package/dist/kernel-globals.d.ts.map +1 -1
  18. package/dist/kernel-globals.js +19 -1
  19. package/dist/manifest-visitor.d.ts +109 -0
  20. package/dist/manifest-visitor.d.ts.map +1 -0
  21. package/dist/manifest-visitor.js +110 -0
  22. package/dist/schema-compat.d.ts +10 -0
  23. package/dist/schema-compat.d.ts.map +1 -1
  24. package/dist/schema-compat.js +32 -0
  25. package/dist/validate-cel-context.d.ts +14 -0
  26. package/dist/validate-cel-context.d.ts.map +1 -1
  27. package/dist/validate-cel-context.js +38 -0
  28. package/dist/validate-references.d.ts.map +1 -1
  29. package/dist/validate-references.js +117 -160
  30. package/dist/validate-unused-declarations.d.ts +25 -0
  31. package/dist/validate-unused-declarations.d.ts.map +1 -0
  32. package/dist/validate-unused-declarations.js +91 -0
  33. package/package.json +2 -2
  34. package/src/analysis-registry.ts +20 -0
  35. package/src/analyzer.ts +157 -169
  36. package/src/builtins.ts +25 -0
  37. package/src/cel-environment.ts +42 -1
  38. package/src/dependency-graph.ts +37 -52
  39. package/src/index.ts +11 -0
  40. package/src/kernel-globals.ts +22 -1
  41. package/src/manifest-visitor.ts +251 -0
  42. package/src/schema-compat.ts +32 -0
  43. package/src/validate-cel-context.ts +50 -0
  44. package/src/validate-references.ts +168 -211
  45. package/src/validate-unused-declarations.ts +95 -0
package/src/analyzer.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import type { Environment } from "@marcbachmann/cel-js";
3
- import { defaultRegistry, walkCelExpressions } from "@telorun/templating";
3
+ import { defaultRegistry, isTaggedSentinel } from "@telorun/templating";
4
4
  import { AliasResolver } from "./alias-resolver.js";
5
5
  import { AnalysisRegistry } from "./analysis-registry.js";
6
6
  import {
@@ -12,6 +12,7 @@ import { DefinitionRegistry } from "./definition-registry.js";
12
12
  import { buildDependencyGraph, formatCycle } from "./dependency-graph.js";
13
13
  import { buildKernelGlobalsSchema, mergeKernelGlobalsIntoContext } from "./kernel-globals.js";
14
14
  import { computeSuggestKind } from "./kind-suggest.js";
15
+ import { visitManifest } from "./manifest-visitor.js";
15
16
  import { isModuleKind } from "./module-kinds.js";
16
17
  import { normalizeInlineResources } from "./normalize-inline-resources.js";
17
18
  import { REF_VALIDATION_SKIP_KINDS } from "./system-kinds.js";
@@ -25,6 +26,7 @@ import {
25
26
  } from "./schema-compat.js";
26
27
  import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisOptions } from "./types.js";
27
28
  import {
29
+ extractContextsFromSchema,
28
30
  getManifestItem,
29
31
  pathMatchesScope,
30
32
  resolveContextAnnotations,
@@ -34,6 +36,7 @@ import { validateExtends } from "./validate-extends.js";
34
36
  import { validateNestedInlineResources } from "./validate-nested-inline.js";
35
37
  import { validateProviderCoherence } from "./validate-provider-coherence.js";
36
38
  import { validateReferences } from "./validate-references.js";
39
+ import { validateUnusedDeclarations } from "./validate-unused-declarations.js";
37
40
  import { validateThrowsCoverage } from "./validate-throws-coverage.js";
38
41
 
39
42
  const SELF_PREFIX = "Self.";
@@ -187,56 +190,6 @@ function manifestRootForResolver(
187
190
  };
188
191
  }
189
192
 
190
- /**
191
- * Walk a JSON Schema tree and collect all `x-telo-context` annotations,
192
- * returning them as `{ scope, schema }` pairs using JSONPath-style scopes —
193
- * the same format the analyzer uses for CEL context validation.
194
- *
195
- * Result is sorted by scope specificity (longer scope first) so that the
196
- * per-expression resolver's first-match-wins logic picks the most-specific
197
- * context. Without this, a broader ancestor scope (e.g. `$.resources[*]`)
198
- * could shadow a narrower descendant scope whose activation differs.
199
- */
200
- function extractContextsFromSchema(
201
- schema: Record<string, any>,
202
- path = "$",
203
- ): Array<{ scope: string; schema: Record<string, any> }> {
204
- const all = collectContexts(schema, path);
205
- return all.sort((a, b) => b.scope.length - a.scope.length);
206
- }
207
-
208
- function collectContexts(
209
- schema: Record<string, any>,
210
- path: string,
211
- ): Array<{ scope: string; schema: Record<string, any> }> {
212
- if (!schema || typeof schema !== "object") return [];
213
- const results: Array<{ scope: string; schema: Record<string, any> }> = [];
214
-
215
- if (schema["x-telo-context"]) {
216
- results.push({ scope: path, schema: schema["x-telo-context"] });
217
- }
218
-
219
- if (schema.properties) {
220
- for (const [key, value] of Object.entries(schema.properties as Record<string, any>)) {
221
- results.push(...collectContexts(value, `${path}.${key}`));
222
- }
223
- }
224
-
225
- if (schema.items && typeof schema.items === "object") {
226
- results.push(...collectContexts(schema.items, `${path}[*]`));
227
- }
228
-
229
- for (const key of ["oneOf", "anyOf", "allOf"] as const) {
230
- if (Array.isArray(schema[key])) {
231
- for (const subschema of schema[key]) {
232
- results.push(...collectContexts(subschema, path));
233
- }
234
- }
235
- }
236
-
237
- return results;
238
- }
239
-
240
193
  /** Resolve a local `$ref` (only `#/$defs/<name>` form) against the root schema.
241
194
  * Non-refs and unresolved refs pass through unchanged. */
242
195
  function resolveLocalRef(
@@ -439,20 +392,31 @@ function collectCelTypeIssues(
439
392
  manifest: ResourceManifest,
440
393
  baseTypedEnv: Environment,
441
394
  rootEnv: Environment,
395
+ rootModuleManifest?: ResourceManifest,
442
396
  ): SchemaIssue[] {
443
397
  const issues: SchemaIssue[] = [];
444
398
 
445
- if (typeof data === "string" && CEL_PURE_RE.test(data)) {
446
- const exprMatch = data.match(CEL_EXPR_RE);
447
- if (exprMatch) {
448
- const expr = exprMatch[1].trim();
399
+ // A pure CEL value type-checks the same regardless of surface form: a
400
+ // `${{ }}` string and a `!cel`-tagged sentinel must behave identically.
401
+ let celExpr: string | undefined;
402
+ if (isTaggedSentinel(data)) {
403
+ // Non-CEL engines (e.g. `!literal`) are analyzed by their own engine pass.
404
+ if (data.engine !== "cel") return issues;
405
+ celExpr = data.source;
406
+ } else if (typeof data === "string" && CEL_PURE_RE.test(data)) {
407
+ celExpr = data.match(CEL_EXPR_RE)?.[1]?.trim();
408
+ }
409
+
410
+ if (celExpr !== undefined) {
411
+ {
412
+ const expr = celExpr;
449
413
 
450
414
  // Merge x-telo-context variables for this path if applicable
451
415
  let typedEnv = baseTypedEnv;
452
416
  if (definition.schema) {
453
417
  for (const ctx of extractContextsFromSchema(definition.schema)) {
454
418
  if (!pathMatchesScope(path, ctx.scope)) continue;
455
- typedEnv = buildTypedCelEnvironment(rootEnv, manifest, ctx.schema);
419
+ typedEnv = buildTypedCelEnvironment(rootEnv, manifest, ctx.schema, rootModuleManifest);
456
420
  break;
457
421
  }
458
422
  }
@@ -475,8 +439,9 @@ function collectCelTypeIssues(
475
439
  } else if (checkResult?.valid && checkResult.type && schema) {
476
440
  const celType = checkResult.type.split("<")[0]!;
477
441
  if (!celTypeSatisfiesJsonSchema(celType, schema)) {
442
+ const expected = schema["x-telo-type"] ?? schema.type ?? "unknown";
478
443
  issues.push({
479
- message: `CEL returns '${checkResult.type}' but field expects '${schema.type ?? "unknown"}'`,
444
+ message: `CEL returns '${checkResult.type}' but field expects '${expected}'`,
480
445
  path,
481
446
  });
482
447
  }
@@ -497,6 +462,7 @@ function collectCelTypeIssues(
497
462
  manifest,
498
463
  baseTypedEnv,
499
464
  rootEnv,
465
+ rootModuleManifest,
500
466
  ),
501
467
  );
502
468
  }
@@ -512,6 +478,7 @@ function collectCelTypeIssues(
512
478
  manifest,
513
479
  baseTypedEnv,
514
480
  rootEnv,
481
+ rootModuleManifest,
515
482
  ),
516
483
  );
517
484
  }
@@ -758,6 +725,13 @@ export class StaticAnalyzer {
758
725
  // recognises variables, secrets, resources, env automatically
759
726
  const kernelGlobals = buildKernelGlobalsSchema(allManifests);
760
727
 
728
+ // The module doc (Application/Library) carries the Application-only `ports`
729
+ // namespace; threaded into per-resource CEL typing so `${{ ports.X }}`
730
+ // resolves its nominal brand cross-doc.
731
+ const moduleManifest =
732
+ allManifests.find((mm) => mm.kind === "Telo.Application") ??
733
+ allManifests.find((mm) => mm.kind === "Telo.Library");
734
+
761
735
  // Validate each non-definition, non-system resource
762
736
  for (const m of allManifests) {
763
737
  const filePath = (m.metadata as { source?: string } | undefined)?.source;
@@ -816,8 +790,17 @@ export class StaticAnalyzer {
816
790
  }
817
791
  : definition.schema;
818
792
  // Phase 1: CEL type checking — walk data+schema together, check env.check() return types
819
- const baseTypedEnv = buildTypedCelEnvironment(this.celEnv, m);
820
- const celIssues = collectCelTypeIssues(m, schema, "", definition, m, baseTypedEnv, this.celEnv);
793
+ const baseTypedEnv = buildTypedCelEnvironment(this.celEnv, m, undefined, moduleManifest);
794
+ const celIssues = collectCelTypeIssues(
795
+ m,
796
+ schema,
797
+ "",
798
+ definition,
799
+ m,
800
+ baseTypedEnv,
801
+ this.celEnv,
802
+ moduleManifest,
803
+ );
821
804
  // Phase 2+3: AJV on substituted data — CEL fields replaced with typed placeholders
822
805
  const ajvIssues = validateAgainstSchema(substituteCelFields(m, schema), schema);
823
806
  const issues = [...celIssues, ...ajvIssues];
@@ -959,125 +942,127 @@ export class StaticAnalyzer {
959
942
  }
960
943
  }
961
944
 
962
- // Validate CEL syntax and context variable access in all manifests
963
- for (const m of allManifests) {
964
- const resource = { kind: m.kind, name: m.metadata?.name as string };
965
- const filePath = (m.metadata as { source?: string } | undefined)?.source;
966
-
967
- const resolvedKind = aliases.resolveKind(m.kind);
968
- const mDefinition =
969
- defs.resolve(m.kind) ?? (resolvedKind ? defs.resolve(resolvedKind) : undefined);
970
-
971
- // Pre-compute step context for manifests with x-telo-step-context
972
- const stepContextSchema = mDefinition?.schema
973
- ? buildStepContextSchema(
974
- m as Record<string, any>,
975
- mDefinition.schema as Record<string, any>,
976
- allManifests as Record<string, any>[],
977
- defs,
978
- aliases,
979
- )
980
- : undefined;
981
-
982
- walkCelExpressions(m, "", (expr, path, engineName) => {
983
- // Resolve the effective context for this expression's path. The
984
- // engine receives a single closed schema and validates member-access
985
- // chains against it; per-path resolution (step context, x-telo-context,
986
- // kernel-globals merge) stays on the analyzer side because it depends
987
- // on analyzer-internal state (definitions, aliases).
988
- const contexts = mDefinition?.schema ? extractContextsFromSchema(mDefinition.schema) : [];
989
- const invocationContext = (m.metadata as any)?.xTeloInvocationContext as
990
- | Record<string, any>
991
- | undefined;
992
-
993
- let matchedContext: Record<string, any> | undefined;
994
- let matchedScope: string | undefined;
995
- for (const ctx of contexts) {
996
- if (pathMatchesScope(path, ctx.scope)) {
997
- matchedContext = ctx.schema;
998
- matchedScope = ctx.scope;
999
- break;
945
+ // Validate CEL syntax and context variable access in all manifests. The
946
+ // walker discovers every compiled CEL node by scanning the value tree and
947
+ // hands back the `x-telo-context` schema matched at the enclosing path; the
948
+ // per-path resolution (step context, kernel-globals merge, x-telo-context-*
949
+ // annotation resolution) stays here because it depends on analyzer-internal
950
+ // state (definitions, aliases, the typed CEL env).
951
+ // Per-resource state computed at enter and read by that resource's CEL
952
+ // sites. The manifest / resource / filePath come straight off each CelSite's
953
+ // `source` (no need to capture them); only the derived step / invocation
954
+ // context which require analyzer state to build — are stashed here.
955
+ let celStepContextSchema: Record<string, any> | undefined;
956
+ let celInvocationContext: Record<string, any> | undefined;
957
+
958
+ visitManifest(
959
+ allManifests,
960
+ defs,
961
+ {
962
+ onResourceEnter: (e) => {
963
+ const m = e.source;
964
+ celInvocationContext = (m.metadata as any)?.xTeloInvocationContext as
965
+ | Record<string, any>
966
+ | undefined;
967
+ celStepContextSchema = e.definition?.schema
968
+ ? buildStepContextSchema(
969
+ m as Record<string, any>,
970
+ e.definition.schema as Record<string, any>,
971
+ allManifests as Record<string, any>[],
972
+ defs,
973
+ aliases,
974
+ )
975
+ : undefined;
976
+ },
977
+ onCel: (e) => {
978
+ const m = e.source;
979
+ const resource = { kind: m.kind, name: m.metadata?.name as string };
980
+ const filePath = (m.metadata as { source?: string } | undefined)?.source;
981
+ const { expr, path, engineName, matchedScope } = e;
982
+
983
+ let matchedContext: Record<string, any> | undefined =
984
+ e.contextSchema ?? celInvocationContext;
985
+
986
+ if (celStepContextSchema) {
987
+ const base =
988
+ matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
989
+ matchedContext = {
990
+ ...base,
991
+ properties: {
992
+ ...(base.properties ?? {}),
993
+ steps: celStepContextSchema,
994
+ },
995
+ };
1000
996
  }
1001
- }
1002
- if (!matchedContext) matchedContext = invocationContext;
1003
-
1004
- if (stepContextSchema) {
1005
- const base = matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
1006
- matchedContext = {
1007
- ...base,
1008
- properties: {
1009
- ...(base.properties ?? {}),
1010
- steps: stepContextSchema,
1011
- },
1012
- };
1013
- }
1014
997
 
1015
- let effectiveContext: Record<string, any> | null = null;
1016
- if (matchedContext) {
1017
- const manifestItem = matchedScope
1018
- ? getManifestItem(path, matchedScope, m as Record<string, any>)
1019
- : (m as Record<string, any>);
1020
- const rootForResolver = manifestRootForResolver(
1021
- m as Record<string, any>,
1022
- defs,
1023
- aliases,
1024
- allManifests as Record<string, any>[],
1025
- );
1026
- const resolvedContext = resolveContextAnnotations(matchedContext, manifestItem, {
1027
- manifestRoot: rootForResolver,
1028
- defs,
1029
- aliases,
1030
- allManifests: allManifests as Record<string, any>[],
1031
- });
1032
- effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
1033
- }
1034
-
1035
- const engine = defaultRegistry().get(engineName);
1036
- if (!engine) {
1037
- // No registered engine owns this tag — the expression would go
1038
- // entirely unanalyzed. Surface it rather than skipping silently.
1039
- diagnostics.push({
1040
- severity: DiagnosticSeverity.Error,
1041
- code: "UNKNOWN_ENGINE",
1042
- source: SOURCE,
1043
- message: `${m.kind}/${resource.name}: no templating engine registered for '!${engineName}' at '${path}'.`,
1044
- data: { resource, filePath, path },
1045
- });
1046
- return;
1047
- }
1048
- const findings = engine.analyze(expr, { celEnv: this.celEnv, contextSchema: effectiveContext });
1049
- for (const f of findings) {
1050
- if (f.code === "CEL_SYNTAX_ERROR") {
1051
- diagnostics.push({
1052
- severity: DiagnosticSeverity.Error,
1053
- code: "CEL_SYNTAX_ERROR",
1054
- source: SOURCE,
1055
- message: `CEL syntax error at ${path}: ${f.message}`,
1056
- data: { resource, filePath, path },
1057
- });
1058
- } else if (f.code === "CEL_UNKNOWN_FIELD") {
1059
- diagnostics.push({
1060
- severity: DiagnosticSeverity.Error,
1061
- code: "CEL_UNKNOWN_FIELD",
1062
- source: SOURCE,
1063
- message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
1064
- data: { resource, filePath, path },
998
+ let effectiveContext: Record<string, any> | null = null;
999
+ if (matchedContext) {
1000
+ const manifestItem = matchedScope
1001
+ ? getManifestItem(path, matchedScope, m as Record<string, any>)
1002
+ : (m as Record<string, any>);
1003
+ const rootForResolver = manifestRootForResolver(
1004
+ m as Record<string, any>,
1005
+ defs,
1006
+ aliases,
1007
+ allManifests as Record<string, any>[],
1008
+ );
1009
+ const resolvedContext = resolveContextAnnotations(matchedContext, manifestItem, {
1010
+ manifestRoot: rootForResolver,
1011
+ defs,
1012
+ aliases,
1013
+ allManifests: allManifests as Record<string, any>[],
1065
1014
  });
1066
- } else {
1067
- // Unknown code from a future engine — pass the message through,
1068
- // tagged with a generic ENGINE_DIAGNOSTIC code so downstream
1069
- // filters can still bucket it.
1015
+ effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
1016
+ }
1017
+
1018
+ const engine = defaultRegistry().get(engineName);
1019
+ if (!engine) {
1020
+ // No registered engine owns this tag — the expression would go
1021
+ // entirely unanalyzed. Surface it rather than skipping silently.
1070
1022
  diagnostics.push({
1071
1023
  severity: DiagnosticSeverity.Error,
1072
- code: f.code ?? "ENGINE_DIAGNOSTIC",
1024
+ code: "UNKNOWN_ENGINE",
1073
1025
  source: SOURCE,
1074
- message: `${m.kind}/${resource.name}: !${engineName} at '${path}': ${f.message}`,
1026
+ message: `${m.kind}/${resource.name}: no templating engine registered for '!${engineName}' at '${path}'.`,
1075
1027
  data: { resource, filePath, path },
1076
1028
  });
1029
+ return;
1077
1030
  }
1078
- }
1079
- });
1080
- }
1031
+ const findings = engine.analyze(expr, { celEnv: this.celEnv, contextSchema: effectiveContext });
1032
+ for (const f of findings) {
1033
+ if (f.code === "CEL_SYNTAX_ERROR") {
1034
+ diagnostics.push({
1035
+ severity: DiagnosticSeverity.Error,
1036
+ code: "CEL_SYNTAX_ERROR",
1037
+ source: SOURCE,
1038
+ message: `CEL syntax error at ${path}: ${f.message}`,
1039
+ data: { resource, filePath, path },
1040
+ });
1041
+ } else if (f.code === "CEL_UNKNOWN_FIELD") {
1042
+ diagnostics.push({
1043
+ severity: DiagnosticSeverity.Error,
1044
+ code: "CEL_UNKNOWN_FIELD",
1045
+ source: SOURCE,
1046
+ message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
1047
+ data: { resource, filePath, path },
1048
+ });
1049
+ } else {
1050
+ // Unknown code from a future engine — pass the message through,
1051
+ // tagged with a generic ENGINE_DIAGNOSTIC code so downstream
1052
+ // filters can still bucket it.
1053
+ diagnostics.push({
1054
+ severity: DiagnosticSeverity.Error,
1055
+ code: f.code ?? "ENGINE_DIAGNOSTIC",
1056
+ source: SOURCE,
1057
+ message: `${m.kind}/${resource.name}: !${engineName} at '${path}': ${f.message}`,
1058
+ data: { resource, filePath, path },
1059
+ });
1060
+ }
1061
+ }
1062
+ },
1063
+ },
1064
+ { aliases },
1065
+ );
1081
1066
 
1082
1067
  // Validate resource references (Phase 3)
1083
1068
  diagnostics.push(
@@ -1093,6 +1078,9 @@ export class StaticAnalyzer {
1093
1078
  // Validate throws: declarations and catches: coverage (rules 1, 2, 4, 7)
1094
1079
  diagnostics.push(...validateThrowsCoverage(allManifests, defs, aliases, this.celEnv));
1095
1080
 
1081
+ // Warn about declared variables / secrets / ports that no CEL references.
1082
+ diagnostics.push(...validateUnusedDeclarations(allManifests, this.celEnv));
1083
+
1096
1084
  // Reroute diagnostics on synthetic (inline-extracted) resources back to
1097
1085
  // the chain root so position-index lookups land on the parent doc.
1098
1086
  return rewriteSyntheticOrigins(diagnostics, allManifests);
package/src/builtins.ts CHANGED
@@ -279,6 +279,31 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
279
279
  },
280
280
  },
281
281
  },
282
+ // Inbound ports the Application listens on. A name-keyed map mirroring
283
+ // `variables`: each entry binds a host env var (`env:`) that supplies a
284
+ // port integer (implicitly typed `integer`, 1–65535), with an optional
285
+ // `default:` used when the env var is unset. `protocol:` (default `tcp`)
286
+ // selects the transport — the runner reads this list to know the
287
+ // exposed ports before launch, and the analyzer brands the resolved
288
+ // `ports.<name>` value (tcp → TcpPort, udp → UdpPort) for static wiring
289
+ // checks. Application-only. See kernel/nodejs/src/application-env.ts.
290
+ ports: {
291
+ type: "object",
292
+ additionalProperties: {
293
+ type: "object",
294
+ required: ["env"],
295
+ properties: {
296
+ env: { type: "string" },
297
+ protocol: {
298
+ type: "string",
299
+ enum: ["tcp", "udp"],
300
+ default: "tcp",
301
+ },
302
+ default: { type: "integer", minimum: 1, maximum: 65535 },
303
+ },
304
+ additionalProperties: false,
305
+ },
306
+ },
282
307
  },
283
308
  required: ["metadata"],
284
309
  additionalProperties: false,
@@ -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");
@@ -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";