@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
@@ -0,0 +1,91 @@
1
+ import { extractAccessChains, INDEX_SEGMENT, walkCelExpressions } from "@telorun/templating";
2
+ import { DiagnosticSeverity } from "./types.js";
3
+ const SOURCE = "telo-analyzer";
4
+ /** Module-doc namespaces whose entries are consumed via `<ns>.<name>` CEL
5
+ * access. One table drives the whole check — adding a namespace is an entry,
6
+ * not a branch. */
7
+ const NAMESPACES = ["variables", "secrets", "ports"];
8
+ /**
9
+ * Warn about declared `variables` / `secrets` / `ports` entries that no CEL
10
+ * expression references. A declared-but-unconsumed entry is dead weight at
11
+ * best and misleading at worst (an unbound `ports` entry makes a runner
12
+ * advertise a port the app never listens on).
13
+ *
14
+ * Generic across all three namespaces. References are collected from every CEL
15
+ * expression (both `${{ … }}` and `!cel`, via `walkCelExpressions`) by
16
+ * extracting member-access chains: a `<ns>.<name>` chain marks `<name>` used.
17
+ * Dynamic access (`<ns>[expr]`, or the namespace passed whole, e.g.
18
+ * `keys(variables)`) yields a chain that stops at the namespace root — that
19
+ * can't be attributed to a name, so the whole namespace is conservatively
20
+ * suppressed to avoid false positives.
21
+ *
22
+ * Application-only: an Application's `variables` / `secrets` / `ports` flow
23
+ * exclusively through CEL (into resource fields, or into `Telo.Import` inputs),
24
+ * so unreferenced means dead. A `Telo.Library`'s `variables` / `secrets` are a
25
+ * public input contract consumed by its controllers — invisible to CEL
26
+ * analysis — so they are deliberately not flagged.
27
+ */
28
+ export function validateUnusedDeclarations(manifests, celEnv) {
29
+ const moduleManifest = manifests.find((m) => m.kind === "Telo.Application");
30
+ if (!moduleManifest)
31
+ return [];
32
+ const declared = new Map();
33
+ for (const ns of NAMESPACES) {
34
+ const block = moduleManifest[ns];
35
+ if (block && typeof block === "object" && !Array.isArray(block)) {
36
+ const names = Object.keys(block);
37
+ if (names.length > 0)
38
+ declared.set(ns, names);
39
+ }
40
+ }
41
+ if (declared.size === 0)
42
+ return [];
43
+ const used = new Map(NAMESPACES.map((ns) => [ns, new Set()]));
44
+ const suppressed = new Set();
45
+ for (const m of manifests) {
46
+ walkCelExpressions(m, "", (expr, _path, engineName) => {
47
+ if (engineName !== "cel")
48
+ return;
49
+ let ast;
50
+ try {
51
+ ast = celEnv.parse(expr).ast;
52
+ }
53
+ catch {
54
+ return; // syntax errors are reported by the CEL engine pass
55
+ }
56
+ for (const chain of extractAccessChains(ast)) {
57
+ const ns = chain[0];
58
+ if (!used.has(ns))
59
+ continue;
60
+ const member = chain[1];
61
+ // No static member after the namespace root — either the namespace is
62
+ // used whole (`keys(ports)` → ["ports"]) or accessed dynamically
63
+ // (`ports[x]` → ["ports", "[*]"]). Neither can be attributed to a
64
+ // declared name, so suppress the namespace rather than false-positive.
65
+ if (member === undefined || member === INDEX_SEGMENT)
66
+ suppressed.add(ns);
67
+ else
68
+ used.get(ns).add(member);
69
+ }
70
+ });
71
+ }
72
+ const diagnostics = [];
73
+ const filePath = moduleManifest.metadata?.source;
74
+ for (const [ns, names] of declared) {
75
+ if (suppressed.has(ns))
76
+ continue;
77
+ const seen = used.get(ns);
78
+ for (const name of names) {
79
+ if (seen.has(name))
80
+ continue;
81
+ diagnostics.push({
82
+ severity: DiagnosticSeverity.Warning,
83
+ code: "UNUSED_DECLARATION",
84
+ source: SOURCE,
85
+ message: `${ns}.${name} is declared but never referenced in any CEL expression.`,
86
+ data: { filePath, path: `${ns}.${name}` },
87
+ });
88
+ }
89
+ }
90
+ return diagnostics;
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -42,7 +42,7 @@
42
42
  "ajv-formats": "^3.0.1",
43
43
  "jsonpath-plus": "^10.3.0",
44
44
  "yaml": "^2.8.3",
45
- "@telorun/templating": "0.3.0"
45
+ "@telorun/templating": "0.3.1"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^20.0.0",
@@ -3,6 +3,7 @@ import { AliasResolver } from "./alias-resolver.js";
3
3
  import { KERNEL_BUILTINS } from "./builtins.js";
4
4
  import { DefinitionRegistry } from "./definition-registry.js";
5
5
  import { computeSuggestKind, computeValidUserFacingKinds } from "./kind-suggest.js";
6
+ import { visitManifest as runVisitManifest, type ManifestVisitor } from "./manifest-visitor.js";
6
7
  import { isRefEntry, isScopeEntry } from "./reference-field-map.js";
7
8
  import type { AnalysisContext } from "./types.js";
8
9
 
@@ -62,6 +63,25 @@ export class AnalysisRegistry {
62
63
  }
63
64
  }
64
65
 
66
+ /**
67
+ * Walks a manifest's annotation sites (refs, scopes, schema-from, CEL) via
68
+ * the shared manifest visitor, bound to this registry's definitions and
69
+ * aliases. The public seam for hosts (editor overview graph, tooling) that
70
+ * need the same site discovery the analyzer's own passes use, without
71
+ * reaching into the internal DefinitionRegistry.
72
+ */
73
+ visitManifest(
74
+ resources: ResourceManifest[],
75
+ visitor: ManifestVisitor,
76
+ opts?: { skipKinds?: ReadonlySet<string>; expand?: boolean },
77
+ ): void {
78
+ runVisitManifest(resources, this.defs, visitor, {
79
+ aliases: this.aliases,
80
+ aliasesByModule: this.aliasesByModule,
81
+ ...opts,
82
+ });
83
+ }
84
+
65
85
  /**
66
86
  * Returns the built-in kernel definitions. The underlying DefinitionRegistry already
67
87
  * seeds these on construction; this method exposes them so callers (e.g. the kernel's
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,14 +26,17 @@ 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,
31
33
  resolveTypeFieldToSchema,
32
34
  } from "./validate-cel-context.js";
33
35
  import { validateExtends } from "./validate-extends.js";
36
+ import { validateNestedInlineResources } from "./validate-nested-inline.js";
34
37
  import { validateProviderCoherence } from "./validate-provider-coherence.js";
35
38
  import { validateReferences } from "./validate-references.js";
39
+ import { validateUnusedDeclarations } from "./validate-unused-declarations.js";
36
40
  import { validateThrowsCoverage } from "./validate-throws-coverage.js";
37
41
 
38
42
  const SELF_PREFIX = "Self.";
@@ -186,56 +190,6 @@ function manifestRootForResolver(
186
190
  };
187
191
  }
188
192
 
189
- /**
190
- * Walk a JSON Schema tree and collect all `x-telo-context` annotations,
191
- * returning them as `{ scope, schema }` pairs using JSONPath-style scopes —
192
- * the same format the analyzer uses for CEL context validation.
193
- *
194
- * Result is sorted by scope specificity (longer scope first) so that the
195
- * per-expression resolver's first-match-wins logic picks the most-specific
196
- * context. Without this, a broader ancestor scope (e.g. `$.resources[*]`)
197
- * could shadow a narrower descendant scope whose activation differs.
198
- */
199
- function extractContextsFromSchema(
200
- schema: Record<string, any>,
201
- path = "$",
202
- ): Array<{ scope: string; schema: Record<string, any> }> {
203
- const all = collectContexts(schema, path);
204
- return all.sort((a, b) => b.scope.length - a.scope.length);
205
- }
206
-
207
- function collectContexts(
208
- schema: Record<string, any>,
209
- path: string,
210
- ): Array<{ scope: string; schema: Record<string, any> }> {
211
- if (!schema || typeof schema !== "object") return [];
212
- const results: Array<{ scope: string; schema: Record<string, any> }> = [];
213
-
214
- if (schema["x-telo-context"]) {
215
- results.push({ scope: path, schema: schema["x-telo-context"] });
216
- }
217
-
218
- if (schema.properties) {
219
- for (const [key, value] of Object.entries(schema.properties as Record<string, any>)) {
220
- results.push(...collectContexts(value, `${path}.${key}`));
221
- }
222
- }
223
-
224
- if (schema.items && typeof schema.items === "object") {
225
- results.push(...collectContexts(schema.items, `${path}[*]`));
226
- }
227
-
228
- for (const key of ["oneOf", "anyOf", "allOf"] as const) {
229
- if (Array.isArray(schema[key])) {
230
- for (const subschema of schema[key]) {
231
- results.push(...collectContexts(subschema, path));
232
- }
233
- }
234
- }
235
-
236
- return results;
237
- }
238
-
239
193
  /** Resolve a local `$ref` (only `#/$defs/<name>` form) against the root schema.
240
194
  * Non-refs and unresolved refs pass through unchanged. */
241
195
  function resolveLocalRef(
@@ -438,20 +392,31 @@ function collectCelTypeIssues(
438
392
  manifest: ResourceManifest,
439
393
  baseTypedEnv: Environment,
440
394
  rootEnv: Environment,
395
+ rootModuleManifest?: ResourceManifest,
441
396
  ): SchemaIssue[] {
442
397
  const issues: SchemaIssue[] = [];
443
398
 
444
- if (typeof data === "string" && CEL_PURE_RE.test(data)) {
445
- const exprMatch = data.match(CEL_EXPR_RE);
446
- if (exprMatch) {
447
- 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;
448
413
 
449
414
  // Merge x-telo-context variables for this path if applicable
450
415
  let typedEnv = baseTypedEnv;
451
416
  if (definition.schema) {
452
417
  for (const ctx of extractContextsFromSchema(definition.schema)) {
453
418
  if (!pathMatchesScope(path, ctx.scope)) continue;
454
- typedEnv = buildTypedCelEnvironment(rootEnv, manifest, ctx.schema);
419
+ typedEnv = buildTypedCelEnvironment(rootEnv, manifest, ctx.schema, rootModuleManifest);
455
420
  break;
456
421
  }
457
422
  }
@@ -474,8 +439,9 @@ function collectCelTypeIssues(
474
439
  } else if (checkResult?.valid && checkResult.type && schema) {
475
440
  const celType = checkResult.type.split("<")[0]!;
476
441
  if (!celTypeSatisfiesJsonSchema(celType, schema)) {
442
+ const expected = schema["x-telo-type"] ?? schema.type ?? "unknown";
477
443
  issues.push({
478
- message: `CEL returns '${checkResult.type}' but field expects '${schema.type ?? "unknown"}'`,
444
+ message: `CEL returns '${checkResult.type}' but field expects '${expected}'`,
479
445
  path,
480
446
  });
481
447
  }
@@ -496,6 +462,7 @@ function collectCelTypeIssues(
496
462
  manifest,
497
463
  baseTypedEnv,
498
464
  rootEnv,
465
+ rootModuleManifest,
499
466
  ),
500
467
  );
501
468
  }
@@ -511,6 +478,7 @@ function collectCelTypeIssues(
511
478
  manifest,
512
479
  baseTypedEnv,
513
480
  rootEnv,
481
+ rootModuleManifest,
514
482
  ),
515
483
  );
516
484
  }
@@ -698,6 +666,32 @@ export class StaticAnalyzer {
698
666
  }
699
667
  }
700
668
 
669
+ // Fail loud on definition schemas AJV cannot compile. `validateAgainstSchema`
670
+ // and `validateWithRefs` swallow compile failures (returning no issues),
671
+ // which would silently skip schema validation for every resource of that
672
+ // kind — surface the broken schema once, anchored on the definition itself.
673
+ for (const m of allManifests) {
674
+ if (m.kind !== "Telo.Definition" && m.kind !== "Telo.Abstract") continue;
675
+ const schema = (m as Record<string, any>).schema;
676
+ if (!schema || typeof schema !== "object") continue;
677
+ const name = m.metadata?.name as string | undefined;
678
+ if (!name) continue;
679
+ const compileError = defs.schemaCompileError(schema as Record<string, any>);
680
+ if (compileError) {
681
+ diagnostics.push({
682
+ severity: DiagnosticSeverity.Error,
683
+ code: "SCHEMA_COMPILE_ERROR",
684
+ source: SOURCE,
685
+ message: `${m.kind}/${name}: definition schema failed to compile: ${compileError}`,
686
+ data: {
687
+ resource: { kind: m.kind, name },
688
+ filePath: (m.metadata as { source?: string } | undefined)?.source,
689
+ path: "schema",
690
+ },
691
+ });
692
+ }
693
+ }
694
+
701
695
  // Library env: rejection — `env:` on a Library `variables` / `secrets`
702
696
  // entry is forbidden. The Library entry schema is otherwise open so that
703
697
  // any JSON Schema property schema is valid; this targeted check produces
@@ -731,6 +725,13 @@ export class StaticAnalyzer {
731
725
  // recognises variables, secrets, resources, env automatically
732
726
  const kernelGlobals = buildKernelGlobalsSchema(allManifests);
733
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
+
734
735
  // Validate each non-definition, non-system resource
735
736
  for (const m of allManifests) {
736
737
  const filePath = (m.metadata as { source?: string } | undefined)?.source;
@@ -789,8 +790,17 @@ export class StaticAnalyzer {
789
790
  }
790
791
  : definition.schema;
791
792
  // Phase 1: CEL type checking — walk data+schema together, check env.check() return types
792
- const baseTypedEnv = buildTypedCelEnvironment(this.celEnv, m);
793
- 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
+ );
794
804
  // Phase 2+3: AJV on substituted data — CEL fields replaced with typed placeholders
795
805
  const ajvIssues = validateAgainstSchema(substituteCelFields(m, schema), schema);
796
806
  const issues = [...celIssues, ...ajvIssues];
@@ -805,6 +815,39 @@ export class StaticAnalyzer {
805
815
  }
806
816
  }
807
817
 
818
+ // Validate inline resources nested inside this resource's body (e.g. a
819
+ // Run.Sequence step's `invoke: { kind, ...config }`). These sit at
820
+ // x-telo-ref slots reached only through local `$ref`s, which the
821
+ // reference field map intentionally does not follow, so they escape both
822
+ // inline-extraction and the per-resource schema check above.
823
+ if (definition.schema) {
824
+ // Resolve inline kinds in the parent resource's scope: direct kind
825
+ // first, then the parent module's own aliases (for resources declared
826
+ // inside an imported module), then the root aliases. Mirrors how the
827
+ // analyzer resolves kinds elsewhere so module-scoped aliases don't
828
+ // produce false UNDEFINED_KIND diagnostics.
829
+ const ownModule = (m.metadata as { module?: string } | undefined)?.module;
830
+ const scopeResolver =
831
+ ownModule && !rootModules.has(ownModule) ? aliasesByModule.get(ownModule) : undefined;
832
+ diagnostics.push(
833
+ ...validateNestedInlineResources(
834
+ m,
835
+ definition.schema as Record<string, any>,
836
+ (kind: string) => {
837
+ const direct = defs.resolve(kind);
838
+ if (direct) return direct;
839
+ const viaScope = scopeResolver?.resolveKind(kind);
840
+ if (viaScope) {
841
+ const scoped = defs.resolve(viaScope);
842
+ if (scoped) return scoped;
843
+ }
844
+ const viaRoot = aliases.resolveKind(kind);
845
+ return viaRoot ? defs.resolve(viaRoot) : undefined;
846
+ },
847
+ ),
848
+ );
849
+ }
850
+
808
851
  // (Invocation context compatibility check is handled via x-telo-context in the CEL pass below)
809
852
  }
810
853
 
@@ -899,114 +942,127 @@ export class StaticAnalyzer {
899
942
  }
900
943
  }
901
944
 
902
- // Validate CEL syntax and context variable access in all manifests
903
- for (const m of allManifests) {
904
- const resource = { kind: m.kind, name: m.metadata?.name as string };
905
- const filePath = (m.metadata as { source?: string } | undefined)?.source;
906
-
907
- const resolvedKind = aliases.resolveKind(m.kind);
908
- const mDefinition =
909
- defs.resolve(m.kind) ?? (resolvedKind ? defs.resolve(resolvedKind) : undefined);
910
-
911
- // Pre-compute step context for manifests with x-telo-step-context
912
- const stepContextSchema = mDefinition?.schema
913
- ? buildStepContextSchema(
914
- m as Record<string, any>,
915
- mDefinition.schema as Record<string, any>,
916
- allManifests as Record<string, any>[],
917
- defs,
918
- aliases,
919
- )
920
- : undefined;
921
-
922
- walkCelExpressions(m, "", (expr, path, engineName) => {
923
- // Resolve the effective context for this expression's path. The
924
- // engine receives a single closed schema and validates member-access
925
- // chains against it; per-path resolution (step context, x-telo-context,
926
- // kernel-globals merge) stays on the analyzer side because it depends
927
- // on analyzer-internal state (definitions, aliases).
928
- const contexts = mDefinition?.schema ? extractContextsFromSchema(mDefinition.schema) : [];
929
- const invocationContext = (m.metadata as any)?.xTeloInvocationContext as
930
- | Record<string, any>
931
- | undefined;
932
-
933
- let matchedContext: Record<string, any> | undefined;
934
- let matchedScope: string | undefined;
935
- for (const ctx of contexts) {
936
- if (pathMatchesScope(path, ctx.scope)) {
937
- matchedContext = ctx.schema;
938
- matchedScope = ctx.scope;
939
- 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
+ };
940
996
  }
941
- }
942
- if (!matchedContext) matchedContext = invocationContext;
943
-
944
- if (stepContextSchema) {
945
- const base = matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
946
- matchedContext = {
947
- ...base,
948
- properties: {
949
- ...(base.properties ?? {}),
950
- steps: stepContextSchema,
951
- },
952
- };
953
- }
954
-
955
- let effectiveContext: Record<string, any> | null = null;
956
- if (matchedContext) {
957
- const manifestItem = matchedScope
958
- ? getManifestItem(path, matchedScope, m as Record<string, any>)
959
- : (m as Record<string, any>);
960
- const rootForResolver = manifestRootForResolver(
961
- m as Record<string, any>,
962
- defs,
963
- aliases,
964
- allManifests as Record<string, any>[],
965
- );
966
- const resolvedContext = resolveContextAnnotations(matchedContext, manifestItem, {
967
- manifestRoot: rootForResolver,
968
- defs,
969
- aliases,
970
- allManifests: allManifests as Record<string, any>[],
971
- });
972
- effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
973
- }
974
997
 
975
- const engine = defaultRegistry().get(engineName);
976
- if (!engine) return;
977
- const findings = engine.analyze(expr, { celEnv: this.celEnv, contextSchema: effectiveContext });
978
- for (const f of findings) {
979
- if (f.code === "CEL_SYNTAX_ERROR") {
980
- diagnostics.push({
981
- severity: DiagnosticSeverity.Error,
982
- code: "CEL_SYNTAX_ERROR",
983
- source: SOURCE,
984
- message: `CEL syntax error at ${path}: ${f.message}`,
985
- data: { resource, filePath, path },
986
- });
987
- } else if (f.code === "CEL_UNKNOWN_FIELD") {
988
- diagnostics.push({
989
- severity: DiagnosticSeverity.Error,
990
- code: "CEL_UNKNOWN_FIELD",
991
- source: SOURCE,
992
- message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
993
- 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>[],
994
1014
  });
995
- } else {
996
- // Unknown code from a future engine — pass the message through,
997
- // tagged with a generic ENGINE_DIAGNOSTIC code so downstream
998
- // 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.
999
1022
  diagnostics.push({
1000
1023
  severity: DiagnosticSeverity.Error,
1001
- code: f.code ?? "ENGINE_DIAGNOSTIC",
1024
+ code: "UNKNOWN_ENGINE",
1002
1025
  source: SOURCE,
1003
- message: `${m.kind}/${resource.name}: !${engineName} at '${path}': ${f.message}`,
1026
+ message: `${m.kind}/${resource.name}: no templating engine registered for '!${engineName}' at '${path}'.`,
1004
1027
  data: { resource, filePath, path },
1005
1028
  });
1029
+ return;
1006
1030
  }
1007
- }
1008
- });
1009
- }
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
+ );
1010
1066
 
1011
1067
  // Validate resource references (Phase 3)
1012
1068
  diagnostics.push(
@@ -1022,6 +1078,9 @@ export class StaticAnalyzer {
1022
1078
  // Validate throws: declarations and catches: coverage (rules 1, 2, 4, 7)
1023
1079
  diagnostics.push(...validateThrowsCoverage(allManifests, defs, aliases, this.celEnv));
1024
1080
 
1081
+ // Warn about declared variables / secrets / ports that no CEL references.
1082
+ diagnostics.push(...validateUnusedDeclarations(allManifests, this.celEnv));
1083
+
1025
1084
  // Reroute diagnostics on synthetic (inline-extracted) resources back to
1026
1085
  // the chain root so position-index lookups land on the parent doc.
1027
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,