@telorun/analyzer 0.12.1 → 1.0.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 (51) hide show
  1. package/dist/analysis-registry.d.ts +13 -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 +154 -83
  6. package/dist/builtins.d.ts.map +1 -1
  7. package/dist/builtins.js +85 -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 +124 -0
  20. package/dist/manifest-visitor.d.ts.map +1 -0
  21. package/dist/manifest-visitor.js +181 -0
  22. package/dist/reference-field-map.js +16 -0
  23. package/dist/resolve-throws-union.d.ts +10 -0
  24. package/dist/resolve-throws-union.d.ts.map +1 -1
  25. package/dist/resolve-throws-union.js +35 -7
  26. package/dist/schema-compat.d.ts +10 -0
  27. package/dist/schema-compat.d.ts.map +1 -1
  28. package/dist/schema-compat.js +32 -0
  29. package/dist/validate-cel-context.d.ts +14 -0
  30. package/dist/validate-cel-context.d.ts.map +1 -1
  31. package/dist/validate-cel-context.js +38 -0
  32. package/dist/validate-references.d.ts.map +1 -1
  33. package/dist/validate-references.js +124 -160
  34. package/dist/validate-unused-declarations.d.ts +25 -0
  35. package/dist/validate-unused-declarations.d.ts.map +1 -0
  36. package/dist/validate-unused-declarations.js +91 -0
  37. package/package.json +3 -3
  38. package/src/analysis-registry.ts +20 -0
  39. package/src/analyzer.ts +256 -168
  40. package/src/builtins.ts +85 -0
  41. package/src/cel-environment.ts +42 -1
  42. package/src/dependency-graph.ts +37 -52
  43. package/src/index.ts +11 -0
  44. package/src/kernel-globals.ts +22 -1
  45. package/src/manifest-visitor.ts +340 -0
  46. package/src/reference-field-map.ts +14 -0
  47. package/src/resolve-throws-union.ts +36 -8
  48. package/src/schema-compat.ts +32 -0
  49. package/src/validate-cel-context.ts +50 -0
  50. package/src/validate-references.ts +175 -211
  51. 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(
@@ -426,6 +379,78 @@ function buildStepContextSchema(
426
379
  return undefined;
427
380
  }
428
381
 
382
+ /**
383
+ * Collect every field annotated with `x-telo-error-context` anywhere in a
384
+ * definition schema (resolving local `$ref`s into `$defs`, cycle-safe), mapping
385
+ * the annotated field name to its declared error-shape schema. The field name
386
+ * is matched against CEL paths so the context applies at any nesting depth under
387
+ * that field — e.g. `error` inside a `catch:` nested inside another `try:`. No
388
+ * specific field name (or `Run.Sequence`) is hardcoded; any composer that tags
389
+ * its error-bearing branch fields opts in the same way.
390
+ */
391
+ function collectErrorContextScopes(
392
+ defSchema: Record<string, any> | undefined,
393
+ ): Map<string, Record<string, any>> {
394
+ const out = new Map<string, Record<string, any>>();
395
+ if (!defSchema || typeof defSchema !== "object") return out;
396
+ const seen = new Set<Record<string, any>>();
397
+
398
+ const walk = (schema: Record<string, any> | undefined): void => {
399
+ if (!schema || typeof schema !== "object" || seen.has(schema)) return;
400
+ seen.add(schema);
401
+
402
+ const props = schema.properties as Record<string, any> | undefined;
403
+ if (props) {
404
+ for (const [fieldName, fieldSchema] of Object.entries(props)) {
405
+ if (fieldSchema && typeof fieldSchema === "object") {
406
+ const errCtx = (fieldSchema as Record<string, any>)["x-telo-error-context"];
407
+ if (errCtx && typeof errCtx === "object" && !out.has(fieldName)) {
408
+ out.set(fieldName, errCtx as Record<string, any>);
409
+ }
410
+ }
411
+ walk(resolveLocalRef(fieldSchema as Record<string, any>, defSchema));
412
+ }
413
+ }
414
+ if (schema.items) walk(resolveLocalRef(schema.items as Record<string, any>, defSchema));
415
+ for (const key of ["oneOf", "anyOf", "allOf"] as const) {
416
+ const arr = schema[key];
417
+ if (Array.isArray(arr)) for (const sub of arr) walk(resolveLocalRef(sub, defSchema));
418
+ }
419
+ if (schema.$defs && typeof schema.$defs === "object") {
420
+ for (const sub of Object.values(schema.$defs as Record<string, any>)) {
421
+ walk(sub as Record<string, any>);
422
+ }
423
+ }
424
+ };
425
+
426
+ walk(defSchema);
427
+ return out;
428
+ }
429
+
430
+ /**
431
+ * Return the error-context schema for a CEL `path` when the path lies within
432
+ * (any depth under) one of the error-bearing fields, else undefined. A path is
433
+ * "within" field `f` when it contains a segment `f[<index>]`. When multiple
434
+ * error-bearing fields match (e.g. a `finally` nested inside a `catch`), the
435
+ * deepest — the one whose segment appears latest in the path — wins, so the
436
+ * innermost branch's schema governs.
437
+ */
438
+ function errorContextForPath(
439
+ path: string,
440
+ scopes: Map<string, Record<string, any>>,
441
+ ): Record<string, any> | undefined {
442
+ let best: { index: number; schema: Record<string, any> } | undefined;
443
+ for (const [fieldName, schema] of scopes) {
444
+ const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
445
+ for (const match of path.matchAll(new RegExp(`(^|\\.)${escaped}\\[\\d+\\]`, "g"))) {
446
+ if (best === undefined || match.index > best.index) {
447
+ best = { index: match.index, schema };
448
+ }
449
+ }
450
+ }
451
+ return best?.schema;
452
+ }
453
+
429
454
  const CEL_PURE_RE = /^\s*\$\{\{[^}]*\}\}\s*$/;
430
455
  const CEL_EXPR_RE = /\$\{\{\s*([^}]+?)\s*\}\}/;
431
456
 
@@ -439,20 +464,31 @@ function collectCelTypeIssues(
439
464
  manifest: ResourceManifest,
440
465
  baseTypedEnv: Environment,
441
466
  rootEnv: Environment,
467
+ rootModuleManifest?: ResourceManifest,
442
468
  ): SchemaIssue[] {
443
469
  const issues: SchemaIssue[] = [];
444
470
 
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();
471
+ // A pure CEL value type-checks the same regardless of surface form: a
472
+ // `${{ }}` string and a `!cel`-tagged sentinel must behave identically.
473
+ let celExpr: string | undefined;
474
+ if (isTaggedSentinel(data)) {
475
+ // Non-CEL engines (e.g. `!literal`) are analyzed by their own engine pass.
476
+ if (data.engine !== "cel") return issues;
477
+ celExpr = data.source;
478
+ } else if (typeof data === "string" && CEL_PURE_RE.test(data)) {
479
+ celExpr = data.match(CEL_EXPR_RE)?.[1]?.trim();
480
+ }
481
+
482
+ if (celExpr !== undefined) {
483
+ {
484
+ const expr = celExpr;
449
485
 
450
486
  // Merge x-telo-context variables for this path if applicable
451
487
  let typedEnv = baseTypedEnv;
452
488
  if (definition.schema) {
453
489
  for (const ctx of extractContextsFromSchema(definition.schema)) {
454
490
  if (!pathMatchesScope(path, ctx.scope)) continue;
455
- typedEnv = buildTypedCelEnvironment(rootEnv, manifest, ctx.schema);
491
+ typedEnv = buildTypedCelEnvironment(rootEnv, manifest, ctx.schema, rootModuleManifest);
456
492
  break;
457
493
  }
458
494
  }
@@ -475,8 +511,9 @@ function collectCelTypeIssues(
475
511
  } else if (checkResult?.valid && checkResult.type && schema) {
476
512
  const celType = checkResult.type.split("<")[0]!;
477
513
  if (!celTypeSatisfiesJsonSchema(celType, schema)) {
514
+ const expected = schema["x-telo-type"] ?? schema.type ?? "unknown";
478
515
  issues.push({
479
- message: `CEL returns '${checkResult.type}' but field expects '${schema.type ?? "unknown"}'`,
516
+ message: `CEL returns '${checkResult.type}' but field expects '${expected}'`,
480
517
  path,
481
518
  });
482
519
  }
@@ -497,6 +534,7 @@ function collectCelTypeIssues(
497
534
  manifest,
498
535
  baseTypedEnv,
499
536
  rootEnv,
537
+ rootModuleManifest,
500
538
  ),
501
539
  );
502
540
  }
@@ -512,6 +550,7 @@ function collectCelTypeIssues(
512
550
  manifest,
513
551
  baseTypedEnv,
514
552
  rootEnv,
553
+ rootModuleManifest,
515
554
  ),
516
555
  );
517
556
  }
@@ -758,6 +797,13 @@ export class StaticAnalyzer {
758
797
  // recognises variables, secrets, resources, env automatically
759
798
  const kernelGlobals = buildKernelGlobalsSchema(allManifests);
760
799
 
800
+ // The module doc (Application/Library) carries the Application-only `ports`
801
+ // namespace; threaded into per-resource CEL typing so `${{ ports.X }}`
802
+ // resolves its nominal brand cross-doc.
803
+ const moduleManifest =
804
+ allManifests.find((mm) => mm.kind === "Telo.Application") ??
805
+ allManifests.find((mm) => mm.kind === "Telo.Library");
806
+
761
807
  // Validate each non-definition, non-system resource
762
808
  for (const m of allManifests) {
763
809
  const filePath = (m.metadata as { source?: string } | undefined)?.source;
@@ -816,8 +862,17 @@ export class StaticAnalyzer {
816
862
  }
817
863
  : definition.schema;
818
864
  // 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);
865
+ const baseTypedEnv = buildTypedCelEnvironment(this.celEnv, m, undefined, moduleManifest);
866
+ const celIssues = collectCelTypeIssues(
867
+ m,
868
+ schema,
869
+ "",
870
+ definition,
871
+ m,
872
+ baseTypedEnv,
873
+ this.celEnv,
874
+ moduleManifest,
875
+ );
821
876
  // Phase 2+3: AJV on substituted data — CEL fields replaced with typed placeholders
822
877
  const ajvIssues = validateAgainstSchema(substituteCelFields(m, schema), schema);
823
878
  const issues = [...celIssues, ...ajvIssues];
@@ -959,125 +1014,155 @@ export class StaticAnalyzer {
959
1014
  }
960
1015
  }
961
1016
 
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;
1017
+ // Validate CEL syntax and context variable access in all manifests. The
1018
+ // walker discovers every compiled CEL node by scanning the value tree and
1019
+ // hands back the `x-telo-context` schema matched at the enclosing path; the
1020
+ // per-path resolution (step context, kernel-globals merge, x-telo-context-*
1021
+ // annotation resolution) stays here because it depends on analyzer-internal
1022
+ // state (definitions, aliases, the typed CEL env).
1023
+ // Per-resource state computed at enter and read by that resource's CEL
1024
+ // sites. The manifest / resource / filePath come straight off each CelSite's
1025
+ // `source` (no need to capture them); only the derived step / invocation
1026
+ // context which require analyzer state to build — are stashed here.
1027
+ let celStepContextSchema: Record<string, any> | undefined;
1028
+ let celInvocationContext: Record<string, any> | undefined;
1029
+ let celErrorScopes: Map<string, Record<string, any>> = new Map();
1030
+
1031
+ visitManifest(
1032
+ allManifests,
1033
+ defs,
1034
+ {
1035
+ onResourceEnter: (e) => {
1036
+ const m = e.source;
1037
+ celInvocationContext = (m.metadata as any)?.xTeloInvocationContext as
1038
+ | Record<string, any>
1039
+ | undefined;
1040
+ celStepContextSchema = e.definition?.schema
1041
+ ? buildStepContextSchema(
1042
+ m as Record<string, any>,
1043
+ e.definition.schema as Record<string, any>,
1044
+ allManifests as Record<string, any>[],
1045
+ defs,
1046
+ aliases,
1047
+ )
1048
+ : undefined;
1049
+ celErrorScopes = collectErrorContextScopes(
1050
+ e.definition?.schema as Record<string, any> | undefined,
1051
+ );
1052
+ },
1053
+ onCel: (e) => {
1054
+ const m = e.source;
1055
+ const resource = { kind: m.kind, name: m.metadata?.name as string };
1056
+ const filePath = (m.metadata as { source?: string } | undefined)?.source;
1057
+ const { expr, path, engineName, matchedScope } = e;
1058
+
1059
+ let matchedContext: Record<string, any> | undefined =
1060
+ e.contextSchema ?? celInvocationContext;
1061
+
1062
+ if (celStepContextSchema) {
1063
+ const base =
1064
+ matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
1065
+ matchedContext = {
1066
+ ...base,
1067
+ properties: {
1068
+ ...(base.properties ?? {}),
1069
+ steps: celStepContextSchema,
1070
+ },
1071
+ };
1000
1072
  }
1001
- }
1002
- if (!matchedContext) matchedContext = invocationContext;
1003
1073
 
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
-
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
- }
1074
+ // `error` is only in scope inside an error-bearing branch (e.g. a
1075
+ // `catch:` / `finally:`), so it's merged per-path, not resource-wide.
1076
+ const errorSchema =
1077
+ celErrorScopes.size > 0 ? errorContextForPath(path, celErrorScopes) : undefined;
1078
+ if (errorSchema) {
1079
+ const base =
1080
+ matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
1081
+ matchedContext = {
1082
+ ...base,
1083
+ properties: {
1084
+ ...(base.properties ?? {}),
1085
+ error: errorSchema,
1086
+ },
1087
+ };
1088
+ }
1034
1089
 
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 },
1090
+ let effectiveContext: Record<string, any> | null = null;
1091
+ if (matchedContext) {
1092
+ const manifestItem = matchedScope
1093
+ ? getManifestItem(path, matchedScope, m as Record<string, any>)
1094
+ : (m as Record<string, any>);
1095
+ const rootForResolver = manifestRootForResolver(
1096
+ m as Record<string, any>,
1097
+ defs,
1098
+ aliases,
1099
+ allManifests as Record<string, any>[],
1100
+ );
1101
+ const resolvedContext = resolveContextAnnotations(matchedContext, manifestItem, {
1102
+ manifestRoot: rootForResolver,
1103
+ defs,
1104
+ aliases,
1105
+ allManifests: allManifests as Record<string, any>[],
1065
1106
  });
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.
1107
+ effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
1108
+ }
1109
+
1110
+ const engine = defaultRegistry().get(engineName);
1111
+ if (!engine) {
1112
+ // No registered engine owns this tag — the expression would go
1113
+ // entirely unanalyzed. Surface it rather than skipping silently.
1070
1114
  diagnostics.push({
1071
1115
  severity: DiagnosticSeverity.Error,
1072
- code: f.code ?? "ENGINE_DIAGNOSTIC",
1116
+ code: "UNKNOWN_ENGINE",
1073
1117
  source: SOURCE,
1074
- message: `${m.kind}/${resource.name}: !${engineName} at '${path}': ${f.message}`,
1118
+ message: `${m.kind}/${resource.name}: no templating engine registered for '!${engineName}' at '${path}'.`,
1075
1119
  data: { resource, filePath, path },
1076
1120
  });
1121
+ return;
1077
1122
  }
1078
- }
1079
- });
1080
- }
1123
+ const findings = engine.analyze(expr, { celEnv: this.celEnv, contextSchema: effectiveContext });
1124
+ for (const f of findings) {
1125
+ if (f.code === "CEL_SYNTAX_ERROR") {
1126
+ diagnostics.push({
1127
+ severity: DiagnosticSeverity.Error,
1128
+ code: "CEL_SYNTAX_ERROR",
1129
+ source: SOURCE,
1130
+ message: `CEL syntax error at ${path}: ${f.message}`,
1131
+ data: { resource, filePath, path },
1132
+ });
1133
+ } else if (f.code === "CEL_UNKNOWN_FIELD") {
1134
+ diagnostics.push({
1135
+ severity: DiagnosticSeverity.Error,
1136
+ code: "CEL_UNKNOWN_FIELD",
1137
+ source: SOURCE,
1138
+ message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
1139
+ data: { resource, filePath, path },
1140
+ });
1141
+ } else if (f.code === "CEL_NULLABLE_ACCESS") {
1142
+ diagnostics.push({
1143
+ severity: DiagnosticSeverity.Error,
1144
+ code: "CEL_NULLABLE_ACCESS",
1145
+ source: SOURCE,
1146
+ message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
1147
+ data: { resource, filePath, path },
1148
+ });
1149
+ } else {
1150
+ // Unknown code from a future engine — pass the message through,
1151
+ // tagged with a generic ENGINE_DIAGNOSTIC code so downstream
1152
+ // filters can still bucket it.
1153
+ diagnostics.push({
1154
+ severity: DiagnosticSeverity.Error,
1155
+ code: f.code ?? "ENGINE_DIAGNOSTIC",
1156
+ source: SOURCE,
1157
+ message: `${m.kind}/${resource.name}: !${engineName} at '${path}': ${f.message}`,
1158
+ data: { resource, filePath, path },
1159
+ });
1160
+ }
1161
+ }
1162
+ },
1163
+ },
1164
+ { aliases },
1165
+ );
1081
1166
 
1082
1167
  // Validate resource references (Phase 3)
1083
1168
  diagnostics.push(
@@ -1093,6 +1178,9 @@ export class StaticAnalyzer {
1093
1178
  // Validate throws: declarations and catches: coverage (rules 1, 2, 4, 7)
1094
1179
  diagnostics.push(...validateThrowsCoverage(allManifests, defs, aliases, this.celEnv));
1095
1180
 
1181
+ // Warn about declared variables / secrets / ports that no CEL references.
1182
+ diagnostics.push(...validateUnusedDeclarations(allManifests, this.celEnv));
1183
+
1096
1184
  // Reroute diagnostics on synthetic (inline-extracted) resources back to
1097
1185
  // the chain root so position-index lookups land on the parent doc.
1098
1186
  return rewriteSyntheticOrigins(diagnostics, allManifests);
package/src/builtins.ts CHANGED
@@ -234,6 +234,66 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
234
234
  },
235
235
  additionalProperties: true,
236
236
  },
237
+ // Gated reference: run() a Runnable/Service only when the
238
+ // `when` CEL guard holds. Discriminated by the `ref` key. `ref`
239
+ // is a bare name or a resolved `!ref` (`{ kind, name }`).
240
+ {
241
+ type: "object",
242
+ required: ["ref"],
243
+ properties: {
244
+ ref: {
245
+ anyOf: [
246
+ { type: "string", "x-telo-ref": "telo#Runnable" },
247
+ { type: "string", "x-telo-ref": "telo#Service" },
248
+ {
249
+ type: "object",
250
+ required: ["kind", "name"],
251
+ properties: {
252
+ kind: { type: "string" },
253
+ name: { type: "string" },
254
+ },
255
+ additionalProperties: true,
256
+ },
257
+ ],
258
+ },
259
+ when: { type: "string" },
260
+ },
261
+ additionalProperties: false,
262
+ },
263
+ // Inline flat invoke step: invoke an Invocable / Runnable on boot
264
+ // with an optional `name` (for steps.<name>.result plumbing),
265
+ // `when` guard, and `inputs`. Discriminated by the `invoke` key.
266
+ // Control flow (if/while/switch/try) is not available here —
267
+ // reach for Run.Sequence. `invoke` is ref-only and must resolve
268
+ // to a `{ kind, name }` reference (a `!ref` / `{kind,name}`):
269
+ // requiring `name` rejects an inline `{ kind }` definition (no
270
+ // name) at analysis instead of failing at boot with an undefined
271
+ // resource name. The Invocable/Runnable kind set mirrors
272
+ // Run.Sequence invoke steps.
273
+ {
274
+ type: "object",
275
+ required: ["invoke"],
276
+ properties: {
277
+ name: { type: "string" },
278
+ invoke: {
279
+ "x-telo-topology-role": "invoke",
280
+ type: "object",
281
+ required: ["kind", "name"],
282
+ properties: {
283
+ kind: { type: "string" },
284
+ name: { type: "string" },
285
+ },
286
+ additionalProperties: true,
287
+ anyOf: [
288
+ { "x-telo-ref": "telo#Invocable" },
289
+ { "x-telo-ref": "telo#Runnable" },
290
+ ],
291
+ },
292
+ inputs: { type: "object", additionalProperties: true },
293
+ when: { type: "string" },
294
+ },
295
+ additionalProperties: false,
296
+ },
237
297
  ],
238
298
  },
239
299
  },
@@ -279,6 +339,31 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
279
339
  },
280
340
  },
281
341
  },
342
+ // Inbound ports the Application listens on. A name-keyed map mirroring
343
+ // `variables`: each entry binds a host env var (`env:`) that supplies a
344
+ // port integer (implicitly typed `integer`, 1–65535), with an optional
345
+ // `default:` used when the env var is unset. `protocol:` (default `tcp`)
346
+ // selects the transport — the runner reads this list to know the
347
+ // exposed ports before launch, and the analyzer brands the resolved
348
+ // `ports.<name>` value (tcp → TcpPort, udp → UdpPort) for static wiring
349
+ // checks. Application-only. See kernel/nodejs/src/application-env.ts.
350
+ ports: {
351
+ type: "object",
352
+ additionalProperties: {
353
+ type: "object",
354
+ required: ["env"],
355
+ properties: {
356
+ env: { type: "string" },
357
+ protocol: {
358
+ type: "string",
359
+ enum: ["tcp", "udp"],
360
+ default: "tcp",
361
+ },
362
+ default: { type: "integer", minimum: 1, maximum: 65535 },
363
+ },
364
+ additionalProperties: false,
365
+ },
366
+ },
282
367
  },
283
368
  required: ["metadata"],
284
369
  additionalProperties: false,