@telorun/analyzer 0.56.1 → 0.58.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 (133) hide show
  1. package/dist/analyzer.d.ts +5 -0
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/analyzer.js +232 -94
  4. package/dist/cel-environment.d.ts.map +1 -1
  5. package/dist/cel-environment.js +6 -3
  6. package/dist/definition-registry.d.ts +5 -0
  7. package/dist/definition-registry.d.ts.map +1 -1
  8. package/dist/definition-registry.js +9 -0
  9. package/dist/index.d.ts +14 -4
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +8 -3
  12. package/dist/invocation-contract.d.ts +21 -13
  13. package/dist/invocation-contract.d.ts.map +1 -1
  14. package/dist/invocation-contract.js +43 -46
  15. package/dist/loaded-types.d.ts +13 -0
  16. package/dist/loaded-types.d.ts.map +1 -1
  17. package/dist/manifest-loader.d.ts +8 -1
  18. package/dist/manifest-loader.d.ts.map +1 -1
  19. package/dist/manifest-loader.js +33 -6
  20. package/dist/manifest-visitor.d.ts +4 -0
  21. package/dist/manifest-visitor.d.ts.map +1 -1
  22. package/dist/manifest-visitor.js +3 -3
  23. package/dist/migrations/driver.d.ts +69 -0
  24. package/dist/migrations/driver.d.ts.map +1 -0
  25. package/dist/migrations/driver.js +200 -0
  26. package/dist/migrations/entries/index.d.ts +3 -0
  27. package/dist/migrations/entries/index.d.ts.map +1 -0
  28. package/dist/migrations/entries/index.js +9 -0
  29. package/dist/migrations/entries/normalize-value-types.json +48 -0
  30. package/dist/migrations/entries/ref-slot-scalar-type.json +20 -0
  31. package/dist/migrations/entry-data.d.ts +25 -0
  32. package/dist/migrations/entry-data.d.ts.map +1 -0
  33. package/dist/migrations/entry-data.js +171 -0
  34. package/dist/migrations/index.d.ts +22 -0
  35. package/dist/migrations/index.d.ts.map +1 -0
  36. package/dist/migrations/index.js +19 -0
  37. package/dist/migrations/match.d.ts +162 -0
  38. package/dist/migrations/match.d.ts.map +1 -0
  39. package/dist/migrations/match.js +295 -0
  40. package/dist/migrations/patch.d.ts +68 -0
  41. package/dist/migrations/patch.d.ts.map +1 -0
  42. package/dist/migrations/patch.js +236 -0
  43. package/dist/migrations/provenance.d.ts +25 -0
  44. package/dist/migrations/provenance.d.ts.map +1 -0
  45. package/dist/migrations/provenance.js +118 -0
  46. package/dist/migrations/registry.d.ts +35 -0
  47. package/dist/migrations/registry.d.ts.map +1 -0
  48. package/dist/migrations/registry.js +35 -0
  49. package/dist/migrations/report.d.ts +28 -0
  50. package/dist/migrations/report.d.ts.map +1 -0
  51. package/dist/migrations/report.js +144 -0
  52. package/dist/migrations/types.d.ts +115 -0
  53. package/dist/migrations/types.d.ts.map +1 -0
  54. package/dist/migrations/types.js +24 -0
  55. package/dist/migrations/yaml-edit.d.ts +21 -0
  56. package/dist/migrations/yaml-edit.d.ts.map +1 -0
  57. package/dist/migrations/yaml-edit.js +245 -0
  58. package/dist/module-file-claims.d.ts +65 -0
  59. package/dist/module-file-claims.d.ts.map +1 -0
  60. package/dist/module-file-claims.js +106 -0
  61. package/dist/parse-loaded-file.d.ts +8 -0
  62. package/dist/parse-loaded-file.d.ts.map +1 -1
  63. package/dist/parse-loaded-file.js +22 -13
  64. package/dist/resolve-schema-type-refs.d.ts +29 -12
  65. package/dist/resolve-schema-type-refs.d.ts.map +1 -1
  66. package/dist/resolve-schema-type-refs.js +89 -16
  67. package/dist/schema-compat.d.ts +35 -9
  68. package/dist/schema-compat.d.ts.map +1 -1
  69. package/dist/schema-compat.js +181 -54
  70. package/dist/schema-region.d.ts +33 -0
  71. package/dist/schema-region.d.ts.map +1 -0
  72. package/dist/schema-region.js +45 -0
  73. package/dist/types.d.ts +51 -0
  74. package/dist/types.d.ts.map +1 -1
  75. package/dist/types.js +6 -0
  76. package/dist/validate-cel-context.d.ts.map +1 -1
  77. package/dist/validate-cel-context.js +29 -3
  78. package/dist/validate-include-placement.d.ts +26 -0
  79. package/dist/validate-include-placement.d.ts.map +1 -0
  80. package/dist/validate-include-placement.js +67 -0
  81. package/dist/validate-step-inputs.d.ts +8 -1
  82. package/dist/validate-step-inputs.d.ts.map +1 -1
  83. package/dist/validate-step-inputs.js +47 -2
  84. package/dist/validate-throws-coverage.d.ts.map +1 -1
  85. package/dist/validate-throws-coverage.js +15 -12
  86. package/dist/validate-value-type-slots.d.ts +43 -0
  87. package/dist/validate-value-type-slots.d.ts.map +1 -0
  88. package/dist/validate-value-type-slots.js +142 -0
  89. package/dist/value-type-keyword.d.ts +60 -0
  90. package/dist/value-type-keyword.d.ts.map +1 -0
  91. package/dist/value-type-keyword.js +119 -0
  92. package/dist/yaml-source-edit.d.ts +56 -0
  93. package/dist/yaml-source-edit.d.ts.map +1 -0
  94. package/dist/yaml-source-edit.js +93 -0
  95. package/package.json +4 -3
  96. package/src/analyzer.ts +276 -129
  97. package/src/cel-environment.ts +6 -3
  98. package/src/definition-registry.ts +10 -0
  99. package/src/index.ts +31 -3
  100. package/src/invocation-contract.ts +43 -48
  101. package/src/loaded-types.ts +13 -0
  102. package/src/manifest-loader.ts +37 -6
  103. package/src/manifest-visitor.ts +11 -3
  104. package/src/migrations/driver.ts +262 -0
  105. package/src/migrations/entries/index.ts +10 -0
  106. package/src/migrations/entries/normalize-value-types.json +48 -0
  107. package/src/migrations/entries/ref-slot-scalar-type.json +20 -0
  108. package/src/migrations/entry-data.ts +207 -0
  109. package/src/migrations/index.ts +22 -0
  110. package/src/migrations/match.ts +382 -0
  111. package/src/migrations/patch.ts +265 -0
  112. package/src/migrations/provenance.ts +167 -0
  113. package/src/migrations/registry.ts +40 -0
  114. package/src/migrations/report.ts +169 -0
  115. package/src/migrations/types.ts +144 -0
  116. package/src/migrations/yaml-edit.ts +260 -0
  117. package/src/module-file-claims.ts +168 -0
  118. package/src/parse-loaded-file.ts +31 -12
  119. package/src/resolve-schema-type-refs.ts +93 -16
  120. package/src/schema-compat.ts +218 -62
  121. package/src/schema-region.ts +46 -0
  122. package/src/types.ts +54 -0
  123. package/src/validate-cel-context.ts +36 -3
  124. package/src/validate-include-placement.ts +70 -0
  125. package/src/validate-step-inputs.ts +56 -1
  126. package/src/validate-throws-coverage.ts +16 -11
  127. package/src/validate-value-type-slots.ts +182 -0
  128. package/src/value-type-keyword.ts +135 -0
  129. package/src/yaml-source-edit.ts +106 -0
  130. package/dist/binary-slot.d.ts +0 -39
  131. package/dist/binary-slot.d.ts.map +0 -1
  132. package/dist/binary-slot.js +0 -58
  133. package/src/binary-slot.ts +0 -71
package/src/analyzer.ts CHANGED
@@ -1,7 +1,14 @@
1
1
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import { canonicalTypeSchemaId, OBSERVED_STATE_KEY } from "@telorun/sdk";
3
3
  import type { Environment } from "@marcbachmann/cel-js";
4
- import { defaultRegistry, isRefSentinel, isTaggedSentinel } from "@telorun/templating";
4
+ import {
5
+ defaultRegistry,
6
+ isRefSentinel,
7
+ isTaggedSentinel,
8
+ plainChainOf,
9
+ type CelSurface,
10
+ } from "@telorun/templating";
11
+ import type { DiagnosticFix } from "./types.js";
5
12
  import {
6
13
  AliasResolver,
7
14
  moduleScopedDefResolver,
@@ -50,11 +57,17 @@ import {
50
57
  validateRefSlotDeclarations,
51
58
  type RefSlotIssue,
52
59
  } from "./validate-ref-slots.js";
60
+ import {
61
+ validateValueTypeSlots,
62
+ type ValueTypeSlotIssue,
63
+ } from "./validate-value-type-slots.js";
53
64
  import { resolveSchemaTypeRefs } from "./resolve-schema-type-refs.js";
54
65
  import { validateSchemaTypeRefs } from "./validate-schema-type-refs.js";
55
66
  import { rewriteSyntheticOrigins } from "./rewrite-synthetic-origins.js";
56
67
  import {
57
68
  celTypeSatisfiesJsonSchema,
69
+ checkSchemaCompatibility,
70
+ navigateSchemaToExprPath,
58
71
  substituteCelFields,
59
72
  validateAgainstSchema,
60
73
  type SchemaIssue,
@@ -84,6 +97,7 @@ import {
84
97
  import { validateExtends } from "./validate-extends.js";
85
98
  import { validateLogging } from "./validate-logging.js";
86
99
  import { validateModuleArtifact } from "./validate-module-artifact.js";
100
+ import { validateIncludePlacement } from "./validate-include-placement.js";
87
101
  import { validateModuleMetadata } from "./validate-module-metadata.js";
88
102
  import { validateBaseMapping } from "./validate-base-mapping.js";
89
103
  import { validateInvocationContract } from "./validate-invocation-contract.js";
@@ -906,89 +920,62 @@ function celAccessChains(env: Environment, expr: string): string[][] {
906
920
  const CEL_PURE_RE = /^\s*\$\{\{[^}]*\}\}\s*$/;
907
921
  const CEL_EXPR_RE = /\$\{\{\s*([^}]+?)\s*\}\}/;
908
922
 
909
- /** Recursively walk `data`+`schema` together, type-checking every pure CEL template
910
- * string via `env.check()`. Returns `SchemaIssue[]` for any type mismatches found. */
911
- function collectCelTypeIssues(
923
+ /** Restore the delimiters an engine's fix was computed without, so the
924
+ * replacement is the whole scalar rather than a bare expression that would be
925
+ * read back as literal text. A tagged scalar carries no wrapper and passes
926
+ * through untouched. */
927
+ function rewrapFix(
928
+ fix: DiagnosticFix | undefined,
929
+ wrapper: CelSurface["wrapper"],
930
+ ): DiagnosticFix | undefined {
931
+ if (!fix || !wrapper) return fix;
932
+ return { replacement: wrapper.prefix + fix.replacement + wrapper.suffix };
933
+ }
934
+
935
+ /** A pure-CEL leaf and the schema of the field it sits in. */
936
+ export interface CelValueSlot {
937
+ readonly path: string;
938
+ readonly schema: Record<string, any>;
939
+ }
940
+
941
+ /** Recursively walk `data`+`schema` together, collecting every pure CEL leaf
942
+ * with the schema of the field holding it.
943
+ *
944
+ * Type *checking* is deliberately not done here. It belongs to the templating
945
+ * engine, which owns the expression's syntax and now runs it once against the
946
+ * environment typed for that path; checking again here would mean two verdicts
947
+ * from two environments about one expression — which is exactly how an opaque
948
+ * "no matching overload" used to survive next to the diagnostic that explained
949
+ * it. What this walk supplies is the half the engine cannot know: the declared
950
+ * type of the slot the value flows into. The comparison happens once both are
951
+ * in hand (`reportCelReturnMismatches`). */
952
+ function collectCelValueSlots(
912
953
  data: unknown,
913
954
  schema: Record<string, any>,
914
955
  path: string,
915
- definition: { schema?: Record<string, any> },
916
- manifest: ResourceManifest,
917
- baseTypedEnv: Environment,
918
- rootEnv: Environment,
919
- rootModuleManifest?: ResourceManifest,
920
- ): SchemaIssue[] {
921
- const issues: SchemaIssue[] = [];
922
-
923
- // A pure CEL value type-checks the same regardless of surface form: a
924
- // `${{ … }}` string and a `!cel`-tagged sentinel must behave identically.
956
+ ): CelValueSlot[] {
957
+ const slots: CelValueSlot[] = [];
958
+
959
+ // A pure CEL value behaves the same regardless of surface form: a
960
+ // `${{ … }}` string and a `!cel`-tagged sentinel are the same expression.
925
961
  let celExpr: string | undefined;
926
962
  if (isTaggedSentinel(data)) {
927
963
  // Non-CEL engines (e.g. `!literal`) are analyzed by their own engine pass.
928
- if (data.engine !== "cel") return issues;
964
+ if (data.engine !== "cel") return slots;
929
965
  celExpr = data.source;
930
966
  } else if (typeof data === "string" && CEL_PURE_RE.test(data)) {
931
967
  celExpr = data.match(CEL_EXPR_RE)?.[1]?.trim();
932
968
  }
933
969
 
934
970
  if (celExpr !== undefined) {
935
- {
936
- const expr = celExpr;
937
-
938
- // Merge x-telo-context variables for this path if applicable
939
- let typedEnv = baseTypedEnv;
940
- if (definition.schema) {
941
- for (const ctx of extractContextsFromSchema(definition.schema)) {
942
- if (!pathMatchesScope(path, ctx.scope)) continue;
943
- typedEnv = buildTypedCelEnvironment(rootEnv, manifest, ctx.schema, rootModuleManifest);
944
- break;
945
- }
946
- }
947
-
948
- let checkResult: ReturnType<typeof typedEnv.check> | undefined;
949
- try {
950
- checkResult = typedEnv.check(expr);
951
- } catch {
952
- /* degrade gracefully */
953
- }
954
-
955
- if (checkResult?.valid === false && checkResult.error) {
956
- // env.check() rejected the expression itself — e.g. wrong method, wrong
957
- // argument types, wrong operator overload. Surface the first line of the
958
- // error message; the tail is a source-code caret diagram we don't need.
959
- const message = String((checkResult.error as { message?: string }).message ?? checkResult.error)
960
- .split("\n")[0]
961
- .trim();
962
- issues.push({ message: `CEL type error: ${message}`, path });
963
- } else if (checkResult?.valid && checkResult.type && schema) {
964
- const celType = checkResult.type.split("<")[0]!;
965
- if (!celTypeSatisfiesJsonSchema(celType, schema)) {
966
- const expected = schema["x-telo-type"] ?? schema.type ?? "unknown";
967
- issues.push({
968
- message: `CEL returns '${checkResult.type}' but field expects '${expected}'`,
969
- path,
970
- });
971
- }
972
- }
973
- }
974
- return issues;
971
+ if (schema) slots.push({ path, schema });
972
+ return slots;
975
973
  }
976
974
 
977
975
  if (Array.isArray(data)) {
978
976
  const itemSchema = (schema.items ?? {}) as Record<string, any>;
979
977
  for (let i = 0; i < data.length; i++) {
980
- issues.push(
981
- ...collectCelTypeIssues(
982
- data[i],
983
- itemSchema,
984
- `${path}[${i}]`,
985
- definition,
986
- manifest,
987
- baseTypedEnv,
988
- rootEnv,
989
- rootModuleManifest,
990
- ),
991
- );
978
+ slots.push(...collectCelValueSlots(data[i], itemSchema, `${path}[${i}]`));
992
979
  }
993
980
  } else if (data !== null && typeof data === "object") {
994
981
  const props = (schema.properties ?? {}) as Record<string, any>;
@@ -997,22 +984,17 @@ function collectCelTypeIssues(
997
984
  ? (schema.additionalProperties as Record<string, any>)
998
985
  : {};
999
986
  for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
1000
- issues.push(
1001
- ...collectCelTypeIssues(
987
+ slots.push(
988
+ ...collectCelValueSlots(
1002
989
  v,
1003
990
  (props[k] ?? mapValueSchema) as Record<string, any>,
1004
991
  path ? `${path}.${k}` : k,
1005
- definition,
1006
- manifest,
1007
- baseTypedEnv,
1008
- rootEnv,
1009
- rootModuleManifest,
1010
992
  ),
1011
993
  );
1012
994
  }
1013
995
  }
1014
996
 
1015
- return issues;
997
+ return slots;
1016
998
  }
1017
999
 
1018
1000
  export interface StaticAnalyzerOptions {
@@ -1226,6 +1208,18 @@ export class StaticAnalyzer {
1226
1208
  const refConstraintIssues: RefConstraintIssue[] = [];
1227
1209
  const refSlotIssues: RefSlotIssue[] = [];
1228
1210
  const zoneSlotIssues: ZoneSlotIssue[] = [];
1211
+ // `x-telo-type` is checked on EVERY manifest, not only on definition docs: a
1212
+ // schema fragment is written wherever a kind declares a schema-valued field,
1213
+ // so an inline `inputType:` on an ordinary resource carries one just as a
1214
+ // definition's `schema:` does. Same scoping as every other schema issue —
1215
+ // the entry's own modules, since a dependency is not the consumer's to fix.
1216
+ const valueTypeSlotIssues: ValueTypeSlotIssue[] = [];
1217
+ for (const m of manifests) {
1218
+ const declaringModule = (m.metadata as { module?: string } | undefined)?.module;
1219
+ if (!declaringModule || rootModules.has(declaringModule)) {
1220
+ valueTypeSlotIssues.push(...validateValueTypeSlots(m as unknown as ResourceManifest));
1221
+ }
1222
+ }
1229
1223
  for (const m of manifests) {
1230
1224
  if (m.kind !== "Telo.Definition" && m.kind !== "Telo.Abstract") continue;
1231
1225
  const def = m as unknown as ResourceDefinition;
@@ -1365,6 +1359,26 @@ export class StaticAnalyzer {
1365
1359
  // accessor split; `readRefSlot` stays lenient so surfaces keep working
1366
1360
  // mid-migration, and this reports what leniency would silently absorb.
1367
1361
  for (const issue of refSlotIssues) diagnostics.push(refSlotIssueDiagnostic(issue));
1362
+ // The same split for `x-telo-type`. Its reader returns a slot with no
1363
+ // entry for a name it does not know, which is what an unrecognized brand
1364
+ // used to do SILENTLY — the slot simply lost its identity.
1365
+ for (const issue of valueTypeSlotIssues) {
1366
+ diagnostics.push({
1367
+ severity: DiagnosticSeverity.Error,
1368
+ code: issue.code,
1369
+ source: SOURCE,
1370
+ message: issue.message,
1371
+ data: {
1372
+ resource: {
1373
+ kind: issue.manifest.kind,
1374
+ name: issue.manifest.metadata?.name as string,
1375
+ },
1376
+ filePath: (issue.manifest.metadata as { source?: string } | undefined)?.source,
1377
+ path: issue.path,
1378
+ ...(issue.fix ? { fix: issue.fix } : {}),
1379
+ },
1380
+ });
1381
+ }
1368
1382
  // Same split for the two zone annotations. Unreadable ones fail in
1369
1383
  // OPPOSITE directions — a dropped requirement is silently unenforced, a
1370
1384
  // dropped provision invents failures — so neither can be left to
@@ -1501,6 +1515,9 @@ export class StaticAnalyzer {
1501
1515
  // these fields, which is precisely why they need a check: a mistyped one
1502
1516
  // has no runtime failure mode that would ever surface it.
1503
1517
  diagnostics.push(...validateModuleMetadata(allManifests, defs, aliases));
1518
+ // A file embed resolves at resource creation, so one written on a doc that
1519
+ // is never instantiated is read by nothing and would ship silently.
1520
+ diagnostics.push(...validateIncludePlacement(allManifests));
1504
1521
  }
1505
1522
  resolveSchemaTypeRefs(allManifests, aliases, aliasesByModule);
1506
1523
 
@@ -1638,6 +1655,24 @@ export class StaticAnalyzer {
1638
1655
  allManifests.find((mm) => mm.kind === "Telo.Application") ??
1639
1656
  allManifests.find((mm) => mm.kind === "Telo.Library");
1640
1657
 
1658
+ // Every pure-CEL leaf, with the slot it flows into. Compared against the
1659
+ // type the engine walk resolves, once both halves exist — see
1660
+ // `collectCelValueSlots`.
1661
+ const celReturnSlots: (CelValueSlot & {
1662
+ manifest: ResourceManifest;
1663
+ resource: { kind: string; name: string };
1664
+ filePath?: string;
1665
+ })[] = [];
1666
+ const celTypeByPath = new Map<ResourceManifest, Map<string, string>>();
1667
+ // The schema an expression RESOLVES TO, beside the CEL type it carries. Both
1668
+ // are needed and neither replaces the other: the CEL type answers "does this
1669
+ // fit the slot at all", the schema answers "do their type arguments agree",
1670
+ // which cel-js cannot express because it types by constructor identity.
1671
+ const celSourceSchemaByPath = new Map<ResourceManifest, Map<string, Record<string, any>>>();
1672
+ // Context-free typed environments, one per manifest. Reused across every
1673
+ // expression in it — see the build site for why a matched context opts out.
1674
+ const typedEnvByManifest = new Map<ResourceManifest, Environment>();
1675
+
1641
1676
  // Validate each non-definition, non-system resource
1642
1677
  for (const m of allManifests) {
1643
1678
  const filePath = (m.metadata as { source?: string } | undefined)?.source;
@@ -1722,7 +1757,15 @@ export class StaticAnalyzer {
1722
1757
  code: "UNDEFINED_KIND",
1723
1758
  source: SOURCE,
1724
1759
  message: `No Telo.Definition found for kind '${m.kind}'.${hint}`,
1725
- data: { resource, filePath, path: "kind", suggestedKind },
1760
+ // `suggestedKind` is kept beside the generic `fix` because it names
1761
+ // what the replacement IS; the fix is how to apply it.
1762
+ data: {
1763
+ resource,
1764
+ filePath,
1765
+ path: "kind",
1766
+ suggestedKind,
1767
+ ...(suggestedKind ? { fix: { replacement: suggestedKind } } : {}),
1768
+ },
1726
1769
  });
1727
1770
  continue;
1728
1771
  }
@@ -1749,37 +1792,12 @@ export class StaticAnalyzer {
1749
1792
  },
1750
1793
  }
1751
1794
  : authorSchema;
1752
- // Phase 1: CEL type checking walk data+schema together, check env.check() return types.
1753
- // A Telo.Import's variables/secrets are a config-only contract evaluated against the
1754
- // IMPORTING module's scope, so type them from the owning module doc (matched by
1755
- // `metadata.module`) and drop `resources`/`env` so referencing them is an error. A
1756
- // library's own internal import is validated against that library in the library's
1757
- // standalone analysis; in this flattened app pass the library doc is absent, so the
1758
- // importer is undefined here and variables/secrets fall back to a permissive `map`
1759
- // (no false positives) while resources/env stay rejected.
1760
- const importerModule =
1761
- m.kind === "Telo.Import"
1762
- ? allManifests.find(
1763
- (mm) =>
1764
- (mm.kind === "Telo.Application" || mm.kind === "Telo.Library") &&
1765
- (mm.metadata as { name?: string } | undefined)?.name ===
1766
- (m.metadata as { module?: string } | undefined)?.module,
1767
- )
1768
- : undefined;
1769
- const baseTypedEnv =
1770
- m.kind === "Telo.Import"
1771
- ? buildImportInputCelEnvironment(this.celEnv, importerModule)
1772
- : buildTypedCelEnvironment(this.celEnv, m, undefined, moduleManifest);
1773
- const celIssues = collectCelTypeIssues(
1774
- m,
1775
- schema,
1776
- "",
1777
- definition,
1778
- m,
1779
- baseTypedEnv,
1780
- this.celEnv,
1781
- moduleManifest,
1782
- );
1795
+ // Phase 1: collect the pure-CEL leaves and the schema of the slot each
1796
+ // flows into. The expression's own type is resolved later, by the
1797
+ // engine walk that owns type-checking; this half only knows the target.
1798
+ for (const slot of collectCelValueSlots(m, schema, "")) {
1799
+ celReturnSlots.push({ manifest: m, resource, filePath, ...slot });
1800
+ }
1783
1801
  // Phase 2+3: AJV on substituted data — CEL fields replaced with typed placeholders
1784
1802
  const ajvIssues = validateAgainstSchema(substituteCelFields(m, schema), schema);
1785
1803
  // Phase 4: value slots that must satisfy a type declared elsewhere on
@@ -1791,7 +1809,7 @@ export class StaticAnalyzer {
1791
1809
  schema,
1792
1810
  allManifests as Record<string, any>[],
1793
1811
  );
1794
- const issues = [...celIssues, ...ajvIssues, ...valueSchemaIssues];
1812
+ const issues = [...ajvIssues, ...valueSchemaIssues];
1795
1813
  for (const issue of issues) {
1796
1814
  diagnostics.push({
1797
1815
  severity: DiagnosticSeverity.Error,
@@ -1988,12 +2006,15 @@ export class StaticAnalyzer {
1988
2006
  defs,
1989
2007
  aliases,
1990
2008
  { aliasesByModule, rootModules },
2009
+ celStepContextSchema,
1991
2010
  )) {
1992
2011
  diagnostics.push({
1993
2012
  severity: DiagnosticSeverity.Error,
1994
- code: "CONTRACT_INPUTS_MISMATCH",
2013
+ code: issue.code ?? "CONTRACT_INPUTS_MISMATCH",
1995
2014
  source: SOURCE,
1996
- message: `${m.kind}/${stepName}: inputs at '${issue.path}' do not satisfy ${issue.targetLabel}'s declared inputType: ${issue.message}`,
2015
+ message: issue.code
2016
+ ? `${m.kind}/${stepName}: inputs at '${issue.path}' flow into ${issue.targetLabel} with disagreeing type arguments: ${issue.message}`
2017
+ : `${m.kind}/${stepName}: inputs at '${issue.path}' do not satisfy ${issue.targetLabel}'s declared inputType: ${issue.message}`,
1997
2018
  data: {
1998
2019
  resource: { kind: m.kind, name: stepName ?? "" },
1999
2020
  filePath: stepFile,
@@ -2244,42 +2265,128 @@ export class StaticAnalyzer {
2244
2265
  });
2245
2266
  return;
2246
2267
  }
2247
- const findings = engine.analyze(expr, { celEnv: this.celEnv, contextSchema: effectiveContext });
2248
- for (const f of findings) {
2249
- if (f.code === "CEL_SYNTAX_ERROR") {
2268
+ // The engine type-checks, so it gets the environment typed for THIS
2269
+ // path not the bare base one. A `Telo.Import`'s variables/secrets
2270
+ // are a config-only contract evaluated in the IMPORTING module's
2271
+ // scope, so they type from the owning module doc and drop
2272
+ // `resources`/`env`, making a reference to either an error.
2273
+ //
2274
+ // Cached per manifest when no `x-telo-context` applied, which is most
2275
+ // expressions: the environment then depends only on the manifest, so
2276
+ // rebuilding it per expression is pure waste — a clone plus a
2277
+ // re-registration of every variable, on every keystroke in the IDE.
2278
+ // A matched context makes the environment path-specific (its schema is
2279
+ // resolved against the enclosing array item), so those still build
2280
+ // fresh rather than risk one item's types leaking into another's.
2281
+ const cached = effectiveContext === null ? typedEnvByManifest.get(m) : undefined;
2282
+ const typedEnv =
2283
+ cached ??
2284
+ (m.kind === "Telo.Import"
2285
+ ? buildImportInputCelEnvironment(
2286
+ this.celEnv,
2287
+ allManifests.find(
2288
+ (mm) =>
2289
+ (mm.kind === "Telo.Application" || mm.kind === "Telo.Library") &&
2290
+ (mm.metadata as { name?: string } | undefined)?.name ===
2291
+ (m.metadata as { module?: string } | undefined)?.module,
2292
+ ),
2293
+ )
2294
+ : buildTypedCelEnvironment(
2295
+ this.celEnv,
2296
+ m,
2297
+ effectiveContext ?? undefined,
2298
+ moduleManifest,
2299
+ ));
2300
+ if (effectiveContext === null && !cached) typedEnvByManifest.set(m, typedEnv);
2301
+
2302
+ const result = engine.analyze(expr, { celEnv: typedEnv, contextSchema: effectiveContext });
2303
+
2304
+ if (result.type !== undefined) {
2305
+ let byPath = celTypeByPath.get(m);
2306
+ if (!byPath) celTypeByPath.set(m, (byPath = new Map()));
2307
+ byPath.set(path, result.type);
2308
+ }
2309
+
2310
+ // The producer half of the type-argument check. A CEL type is a bare
2311
+ // name — cel-js types by constructor identity, so a byte stream and a
2312
+ // string stream are one CEL type and always will be — so the argument
2313
+ // comparison is a parallel pass over the SCHEMAS the analyzer already
2314
+ // walks. This is where the producer's schema is in hand: navigating
2315
+ // the expression's chain into its context schema is exactly "the
2316
+ // producer's outputType at the expression's tail".
2317
+ //
2318
+ // Only a PLAIN CHAIN is navigated — `steps.encode.result.output`, the
2319
+ // shape a wiring site actually takes. An expression that computes
2320
+ // rather than names has no schema to read off the context, so it
2321
+ // records nothing and no argument check fires: silence where the
2322
+ // analyzer knows least is the conservative direction, and the same one
2323
+ // `x-telo-step-context`'s pure-`value` typing takes.
2324
+ const chain = effectiveContext ? plainChainOf(`\${{${expr}}}`) : undefined;
2325
+ if (chain) {
2326
+ const produced = navigateSchemaToExprPath(effectiveContext!, chain);
2327
+ if (produced) {
2328
+ let byPath = celSourceSchemaByPath.get(m);
2329
+ if (!byPath) celSourceSchemaByPath.set(m, (byPath = new Map()));
2330
+ byPath.set(path, produced);
2331
+ }
2332
+ }
2333
+
2334
+ // A non-deterministic call in a compile-eval field is baked once at
2335
+ // load: `nowIso()` there freezes at boot. Sometimes that is the
2336
+ // intent (a boot timestamp, a run id), so it warns rather than
2337
+ // blocking. The engine reports which calls re-evaluate; the eval mode
2338
+ // is manifest policy and stays here.
2339
+ if (celRuleApplies && evalPathsCover(celCompilePaths, path)) {
2340
+ const volatile = [
2341
+ ...new Set(result.calls.filter((c) => c.deterministic === false).map((c) => c.name)),
2342
+ ].sort();
2343
+ if (volatile.length > 0) {
2250
2344
  diagnostics.push({
2251
- severity: DiagnosticSeverity.Error,
2252
- code: "CEL_SYNTAX_ERROR",
2345
+ severity: DiagnosticSeverity.Warning,
2346
+ code: "CEL_NONDETERMINISTIC_IN_COMPILE_FIELD",
2253
2347
  source: SOURCE,
2254
- message: `CEL syntax error at ${path}: ${f.message}`,
2348
+ message: `${m.kind}/${resource.name}: '${path}' is evaluated once at startup, so ${volatile.map((n) => `\`${n}()\``).join(", ")} ${volatile.length === 1 ? "is" : "are"} baked in at load and never re-evaluated. Move the expression to a field evaluated per call (x-telo-eval: runtime) if it should change over time.`,
2255
2349
  data: { resource, filePath, path },
2256
2350
  });
2257
- } else if (f.code === "CEL_UNKNOWN_FIELD") {
2351
+ }
2352
+ }
2353
+
2354
+ for (const f of result.diagnostics) {
2355
+ // A repair is applicable only when the analyzed expression covers
2356
+ // the whole scalar. For one `${{ }}` among literal text, replacing
2357
+ // the node would drop the text around it, so the correction stays
2358
+ // in the message and no fix is stamped.
2359
+ const fix = e.surface.whole ? rewrapFix(f.fix, e.surface.wrapper) : undefined;
2360
+ const data = { resource, filePath, path, ...(fix ? { fix } : {}) };
2361
+ if (f.code === "CEL_SYNTAX_ERROR") {
2258
2362
  diagnostics.push({
2259
2363
  severity: DiagnosticSeverity.Error,
2260
- code: "CEL_UNKNOWN_FIELD",
2364
+ code: "CEL_SYNTAX_ERROR",
2261
2365
  source: SOURCE,
2262
- message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
2263
- data: { resource, filePath, path },
2366
+ message: `CEL syntax error at ${path}: ${f.message}`,
2367
+ data,
2264
2368
  });
2265
- } else if (f.code === "CEL_NULLABLE_ACCESS") {
2369
+ } else if (f.code === undefined) {
2370
+ // No code from a future engine — pass the message through, tagged
2371
+ // with a generic ENGINE_DIAGNOSTIC code so downstream filters can
2372
+ // still bucket it.
2266
2373
  diagnostics.push({
2267
2374
  severity: DiagnosticSeverity.Error,
2268
- code: "CEL_NULLABLE_ACCESS",
2375
+ code: "ENGINE_DIAGNOSTIC",
2269
2376
  source: SOURCE,
2270
- message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
2271
- data: { resource, filePath, path },
2377
+ message: `${m.kind}/${resource.name}: !${engineName} at '${path}': ${f.message}`,
2378
+ data,
2272
2379
  });
2273
2380
  } else {
2274
- // Unknown code from a future engine pass the message through,
2275
- // tagged with a generic ENGINE_DIAGNOSTIC code so downstream
2276
- // filters can still bucket it.
2381
+ // Named by ENGINE, not hardcoded to CEL: the seam exists so a
2382
+ // second engine can produce coded findings, and labelling them
2383
+ // `CEL` would misattribute the first one that does.
2277
2384
  diagnostics.push({
2278
2385
  severity: DiagnosticSeverity.Error,
2279
- code: f.code ?? "ENGINE_DIAGNOSTIC",
2386
+ code: f.code,
2280
2387
  source: SOURCE,
2281
2388
  message: `${m.kind}/${resource.name}: !${engineName} at '${path}': ${f.message}`,
2282
- data: { resource, filePath, path },
2389
+ data,
2283
2390
  });
2284
2391
  }
2285
2392
  }
@@ -2288,6 +2395,46 @@ export class StaticAnalyzer {
2288
2395
  { aliases },
2289
2396
  );
2290
2397
 
2398
+ // The two halves of "does this expression fit the slot it flows into" meet
2399
+ // here: the engine resolved the expression's type during the walk above,
2400
+ // and the schema walk recorded the slot. An expression that failed to check
2401
+ // recorded no type and is already reported by its own diagnostic.
2402
+ for (const slot of celReturnSlots) {
2403
+ const type = celTypeByPath.get(slot.manifest)?.get(slot.path);
2404
+ if (type === undefined) continue;
2405
+ if (!celTypeSatisfiesJsonSchema(type.split("<")[0]!, slot.schema)) {
2406
+ const expected = slot.schema["x-telo-type"] ?? slot.schema.type ?? "unknown";
2407
+ diagnostics.push({
2408
+ severity: DiagnosticSeverity.Error,
2409
+ code: "CEL_TYPE_ERROR",
2410
+ source: SOURCE,
2411
+ message: `${slot.resource.kind}/${slot.resource.name}: CEL at '${slot.path}' returns '${type}' but the field expects '${expected}'.`,
2412
+ data: { resource: slot.resource, filePath: slot.filePath, path: slot.path },
2413
+ });
2414
+ continue;
2415
+ }
2416
+ // The type fits; do its ARGUMENTS agree? Covariant and gradual — an
2417
+ // omitted argument is *any* in both directions, so an unmigrated producer
2418
+ // or consumer is never reported, and only a definite conflict is.
2419
+ const produced = celSourceSchemaByPath.get(slot.manifest)?.get(slot.path);
2420
+ if (!produced) continue;
2421
+ const { compatible, issues } = checkSchemaCompatibility(
2422
+ produced,
2423
+ slot.schema,
2424
+ (ref: string) => defs.schemaForId(ref),
2425
+ );
2426
+ if (compatible) continue;
2427
+ diagnostics.push({
2428
+ severity: DiagnosticSeverity.Error,
2429
+ code: "CEL_TYPE_ARGUMENT_MISMATCH",
2430
+ source: SOURCE,
2431
+ message:
2432
+ `${slot.resource.kind}/${slot.resource.name}: CEL at '${slot.path}' produces a value ` +
2433
+ `whose type arguments disagree with the field's: ${issues.join("; ")}.`,
2434
+ data: { resource: slot.resource, filePath: slot.filePath, path: slot.path },
2435
+ });
2436
+ }
2437
+
2291
2438
  // Validate resource references (Phase 3)
2292
2439
  diagnostics.push(
2293
2440
  ...validateReferences(allManifests, { aliases, definitions: defs, aliasesByModule }),
@@ -3,10 +3,13 @@ import type { ResourceManifest } from "@telorun/sdk";
3
3
  import { jsonSchemaToCelType, VALUE_BRAND_BASE } from "./schema-compat.js";
4
4
 
5
5
  /** Transport protocol on a `ports` entry → the nominal CEL brand its resolved
6
- * value carries. Mirrors the `protocol` enum in the Application schema. */
6
+ * value carries. Mirrors the `protocol` enum in the Application schema, and
7
+ * names the value types by their canonical `Telo.`-qualified names — the same
8
+ * spelling an author writes at `x-telo-type`, so a branded port and a branded
9
+ * field are comparable by name with nothing in between to translate. */
7
10
  const PORT_PROTOCOL_BRAND: Record<string, string> = {
8
- tcp: "TcpPort",
9
- udp: "UdpPort",
11
+ tcp: "Telo.TcpPort",
12
+ udp: "Telo.UdpPort",
10
13
  };
11
14
 
12
15
  export { buildCelEnvironment } from "@telorun/templating";
@@ -127,6 +127,16 @@ export class DefinitionRegistry {
127
127
  return this.registeredSchemaIds.has(id) || this.ajv.getSchema(id) !== undefined;
128
128
  }
129
129
 
130
+ /** The schema registered under `id`, for a structural comparison that must see
131
+ * THROUGH a named shape. Declaring a shape once and referencing it is the
132
+ * sanctioned way to reuse one, so a comparator that cannot follow the
133
+ * reference judges two opaque nodes and learns nothing. */
134
+ schemaForId(id: string): Record<string, any> | undefined {
135
+ const compiled = this.ajv.getSchema(id);
136
+ const schema = compiled?.schema;
137
+ return schema && typeof schema === "object" ? (schema as Record<string, any>) : undefined;
138
+ }
139
+
130
140
  /** Validates data against a schema using this registry's AJV instance, which has all
131
141
  * registered definition schemas loaded — enabling cross-module $ref resolution.
132
142
  * A compile failure returns `[]` here; it is surfaced loudly (once, on the