@telorun/analyzer 0.21.0 → 0.23.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 (41) hide show
  1. package/README.md +9 -17
  2. package/dist/alias-resolver.d.ts +7 -0
  3. package/dist/alias-resolver.d.ts.map +1 -1
  4. package/dist/alias-resolver.js +14 -0
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/analyzer.js +59 -15
  7. package/dist/builtins.d.ts.map +1 -1
  8. package/dist/builtins.js +10 -9
  9. package/dist/cel-environment.d.ts +8 -0
  10. package/dist/cel-environment.d.ts.map +1 -1
  11. package/dist/cel-environment.js +48 -0
  12. package/dist/flatten-for-analyzer.d.ts +52 -0
  13. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  14. package/dist/flatten-for-analyzer.js +192 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/resolve-ref-sentinels.d.ts +22 -7
  19. package/dist/resolve-ref-sentinels.d.ts.map +1 -1
  20. package/dist/resolve-ref-sentinels.js +46 -136
  21. package/dist/schema-compat.d.ts.map +1 -1
  22. package/dist/schema-compat.js +28 -8
  23. package/dist/validate-cel-context.d.ts.map +1 -1
  24. package/dist/validate-cel-context.js +5 -0
  25. package/dist/validate-reference-forms.d.ts +28 -0
  26. package/dist/validate-reference-forms.d.ts.map +1 -0
  27. package/dist/validate-reference-forms.js +91 -0
  28. package/dist/validate-references.d.ts.map +1 -1
  29. package/dist/validate-references.js +4 -37
  30. package/package.json +2 -2
  31. package/src/alias-resolver.ts +14 -0
  32. package/src/analyzer.ts +69 -19
  33. package/src/builtins.ts +10 -9
  34. package/src/cel-environment.ts +57 -0
  35. package/src/flatten-for-analyzer.ts +217 -4
  36. package/src/index.ts +7 -0
  37. package/src/resolve-ref-sentinels.ts +39 -133
  38. package/src/schema-compat.ts +27 -8
  39. package/src/validate-cel-context.ts +5 -0
  40. package/src/validate-reference-forms.ts +110 -0
  41. package/src/validate-references.ts +4 -39
@@ -0,0 +1,91 @@
1
+ import { isTaggedSentinel } from "@telorun/templating";
2
+ import { visitManifest } from "./manifest-visitor.js";
3
+ import { REF_VALIDATION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
4
+ import { DiagnosticSeverity } from "./types.js";
5
+ const SOURCE = "telo-analyzer";
6
+ /**
7
+ * Reference-form validation — the single enforcement point for "a reference is
8
+ * written `!ref <name>` (or `!ref <Alias>.<name>`), nothing else".
9
+ *
10
+ * Runs on the RAW manifest set, BEFORE inline-resource extraction and `!ref`
11
+ * sentinel resolution. That ordering is load-bearing: only at this point is an
12
+ * author-written value still distinguishable from the resolver's own
13
+ * substitution. After normalization both an author's `{kind, name}` and a
14
+ * resolved `!ref` are the same `{kind, name}` object, so no later pass — and no
15
+ * JSON Schema — can tell them apart.
16
+ *
17
+ * At every `x-telo-ref` slot the only accepted value is:
18
+ * - a `!ref` sentinel (or any tagged sentinel — e.g. a `${{ }}` ref passed
19
+ * through a template), or
20
+ * - an inline definition: a plain object with a `kind` and NO `name` (the
21
+ * extractor assigns the name), or
22
+ * - a `${{ }}` CEL expression string (a reference flowed through CEL).
23
+ *
24
+ * Rejected, each with an actionable diagnostic pointing at `!ref`:
25
+ * - the object form `{ kind, name }` (the old reference object), and
26
+ * - a bare string (the old name / dotted-FQN reference).
27
+ */
28
+ export function validateReferenceForms(resources, registry, aliases, aliasesByModule) {
29
+ if (!aliases)
30
+ return [];
31
+ const diagnostics = [];
32
+ const isForeign = (r) => r.metadata?.forwardedExport === true;
33
+ const localResources = resources.filter((r) => !isForeign(r));
34
+ visitManifest(localResources, registry, {
35
+ onRef: (e) => {
36
+ const value = e.value;
37
+ // `!ref` and `!cel`/`${{ }}` sentinels are the supported shapes.
38
+ if (isTaggedSentinel(value))
39
+ return;
40
+ const r = e.source;
41
+ const resourceLabel = `${r.kind}/${r.metadata.name}`;
42
+ const resourceData = { kind: r.kind, name: r.metadata.name };
43
+ const filePath = r.metadata?.source;
44
+ const path = e.concretePath;
45
+ if (typeof value === "string") {
46
+ // A `${{ }}` reference flowed through CEL is fine; any other bare
47
+ // string at a ref slot is the removed string / dotted-FQN form.
48
+ if (value.includes("${{"))
49
+ return;
50
+ diagnostics.push({
51
+ severity: DiagnosticSeverity.Error,
52
+ code: "INVALID_REFERENCE_FORM",
53
+ source: SOURCE,
54
+ message: `${resourceLabel}: string reference at '${path}' → '${value}' is not supported; write it as '!ref ${refHint(value)}'`,
55
+ data: { resource: resourceData, filePath, path },
56
+ });
57
+ return;
58
+ }
59
+ if (value && typeof value === "object" && !Array.isArray(value)) {
60
+ const obj = value;
61
+ // A plain object is an inline definition unless it names a resource —
62
+ // a `name` makes it the removed `{ kind, name }` reference object.
63
+ if (typeof obj.name === "string" && typeof obj.kind === "string") {
64
+ diagnostics.push({
65
+ severity: DiagnosticSeverity.Error,
66
+ code: "INVALID_REFERENCE_FORM",
67
+ source: SOURCE,
68
+ message: `${resourceLabel}: object reference '{ kind, name }' at '${path}' is not supported; write it as '!ref ${obj.name}'`,
69
+ data: { resource: resourceData, filePath, path },
70
+ });
71
+ }
72
+ }
73
+ },
74
+ }, {
75
+ aliases,
76
+ aliasesByModule,
77
+ skipKinds: SYSTEM_KINDS,
78
+ expand: true,
79
+ discoverNestedRefs: true,
80
+ });
81
+ return diagnostics;
82
+ }
83
+ /** Best-effort name for the `!ref` suggestion in a string-ref diagnostic: a
84
+ * dotted-FQN (`Http.Api.UsersApi`) keeps its last segment, an alias-qualified
85
+ * name (`Console.writeLine`) is left intact, a bare name passes through. */
86
+ function refHint(value) {
87
+ const dotCount = (value.match(/\./g) ?? []).length;
88
+ if (dotCount >= 2)
89
+ return value.slice(value.lastIndexOf(".") + 1);
90
+ return value;
91
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"validate-references.d.ts","sourceRoot":"","sources":["../src/validate-references.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAMrD,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA4C/F;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,EAAE,eAAe,GACvB,kBAAkB,EAAE,CAgftB"}
1
+ {"version":3,"file":"validate-references.d.ts","sourceRoot":"","sources":["../src/validate-references.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAMrD,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA4C/F;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,EAAE,eAAe,GACvB,kBAAkB,EAAE,CA6ctB"}
@@ -272,43 +272,10 @@ export function validateReferences(resources, context) {
272
272
  }
273
273
  return;
274
274
  }
275
- // Name-only reference (plain string) look up by name to validate.
276
- // Qualified references use "Kind.Name" format (e.g. "Http.Api.PaymentApi");
277
- // extract the resource name from the last dot segment.
278
- if (typeof val === "string") {
279
- const lastDot = val.lastIndexOf(".");
280
- const refName = lastDot > 0 ? val.slice(lastDot + 1) : val;
281
- const refKindPrefix = lastDot > 0 ? val.slice(0, lastDot) : undefined;
282
- const target = byName.get(refName) ?? visibleScopeManifests.find((m) => m.metadata?.name === refName);
283
- if (!target) {
284
- // Cross-module reference: "Alias.ResourceName" (single dot, bare alias prefix).
285
- // The resource lives in the imported module's scope and can't be validated here.
286
- // Multi-dot prefixes like "Alias.Kind.Name" are local resources with qualified
287
- // kinds — those must be validated.
288
- if (refKindPrefix && !refKindPrefix.includes(".") && aliases.hasAlias(refKindPrefix)) {
289
- return;
290
- }
291
- diagnostics.push({
292
- severity: DiagnosticSeverity.Error,
293
- code: "UNRESOLVED_REFERENCE",
294
- source: SOURCE,
295
- message: `${resourceLabel}: reference at '${concretePath}' → resource '${val}' not found`,
296
- data: { resource: resourceData, filePath, path: concretePath },
297
- });
298
- return;
299
- }
300
- const kindErrors = checkKind(target.kind, entry, registry, aliases);
301
- if (kindErrors.length > 0) {
302
- diagnostics.push({
303
- severity: DiagnosticSeverity.Error,
304
- code: "REFERENCE_KIND_MISMATCH",
305
- source: SOURCE,
306
- message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
307
- data: { resource: resourceData, filePath, path: concretePath },
308
- });
309
- }
310
- return;
311
- }
275
+ // Bare strings are no longer a reference shape `validateReferenceForms`
276
+ // rejects an author-written string at a ref slot before this pass runs,
277
+ // and a `${{ }}` reference flowed through CEL is resolved/typed
278
+ // elsewhere. Anything still a string here is not a reference to resolve.
312
279
  if (typeof val !== "object")
313
280
  return;
314
281
  const refVal = val;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.21.0",
3
+ "version": "0.23.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.7.0"
45
+ "@telorun/templating": "0.8.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^20.0.0",
@@ -3,6 +3,10 @@
3
3
  export class AliasResolver {
4
4
  private readonly importAliases = new Map<string, string>();
5
5
  private readonly importedKinds = new Map<string, Set<string>>();
6
+ /** `${alias}.${suffix}` → canonical `<owningModule>.<Kind>` for kinds an import
7
+ * transitively RE-EXPORTS (`exports.kinds: [Alias.Kind]`), which don't live in the
8
+ * import's own module. Resolved before the normal `<module>.<suffix>` construction. */
9
+ private readonly reExportedKinds = new Map<string, string>();
6
10
 
7
11
  registerImport(alias: string, targetModule: string, exportedKinds: string[]): void {
8
12
  this.importAliases.set(alias, targetModule);
@@ -11,6 +15,12 @@ export class AliasResolver {
11
15
  }
12
16
  }
13
17
 
18
+ /** Register that `<alias>.<suffix>` re-exports the kind canonically named `canonicalKind`
19
+ * (owned by a module the alias's target imports, possibly several hops away). */
20
+ registerKindReExport(alias: string, suffix: string, canonicalKind: string): void {
21
+ this.reExportedKinds.set(`${alias}.${suffix}`, canonicalKind);
22
+ }
23
+
14
24
  /** Real module name an alias points at (e.g. "Console" → "console"), or undefined.
15
25
  * Used to resolve an alias-qualified instance reference "Console.writeLine" to the
16
26
  * forwarded resource declared in that module. The `exports.resources` gate is enforced
@@ -29,6 +39,10 @@ export class AliasResolver {
29
39
  if (dot === -1) return undefined;
30
40
  const prefix = kind.slice(0, dot);
31
41
  const suffix = kind.slice(dot + 1);
42
+ // Re-export takes precedence: a re-exported kind resolves to its true owning module,
43
+ // not `${prefix-target}.${suffix}` (and bypasses the gate — it's explicitly re-exported).
44
+ const reExported = this.reExportedKinds.get(`${prefix}.${suffix}`);
45
+ if (reExported) return reExported;
32
46
  const realModule = this.importAliases.get(prefix);
33
47
  if (!realModule) return undefined;
34
48
  const allowed = this.importedKinds.get(prefix);
package/src/analyzer.ts CHANGED
@@ -5,6 +5,7 @@ import { AliasResolver } from "./alias-resolver.js";
5
5
  import { AnalysisRegistry } from "./analysis-registry.js";
6
6
  import {
7
7
  buildCelEnvironment,
8
+ buildImportInputCelEnvironment,
8
9
  buildTypedCelEnvironment,
9
10
  type CelHandlers,
10
11
  } from "./cel-environment.js";
@@ -36,6 +37,7 @@ import { validateExtends } from "./validate-extends.js";
36
37
  import { validateNestedInlineResources } from "./validate-nested-inline.js";
37
38
  import { validateProviderCoherence } from "./validate-provider-coherence.js";
38
39
  import { validateReferences } from "./validate-references.js";
40
+ import { validateReferenceForms } from "./validate-reference-forms.js";
39
41
  import { validateUnusedDeclarations } from "./validate-unused-declarations.js";
40
42
  import { validateThrowsCoverage } from "./validate-throws-coverage.js";
41
43
 
@@ -705,16 +707,22 @@ export class StaticAnalyzer {
705
707
  if (resolvedModuleName) {
706
708
  defs.registerModuleIdentity(resolvedNamespace ?? null, resolvedModuleName);
707
709
  }
710
+ // `metadata.reExportedKinds` (stamped by flattenForAnalyzer / the editor projection)
711
+ // maps an exported suffix to the true owning module's canonical kind for kinds this
712
+ // import transitively re-exports (`exports.kinds: [Alias.Kind]`).
713
+ const reExportedKinds = ((m.metadata as any)?.reExportedKinds ?? {}) as Record<
714
+ string,
715
+ string
716
+ >;
708
717
  // Alias registration is scoped: consumer imports vs. imported-library imports.
709
- if (!ownModule || rootModules.has(ownModule)) {
710
- aliases.registerImport(alias, targetModule, exportedKinds);
711
- } else {
712
- let libResolver = aliasesByModule.get(ownModule);
713
- if (!libResolver) {
714
- libResolver = new AliasResolver();
715
- aliasesByModule.set(ownModule, libResolver);
716
- }
717
- libResolver.registerImport(alias, targetModule, exportedKinds);
718
+ const resolver =
719
+ !ownModule || rootModules.has(ownModule)
720
+ ? aliases
721
+ : (aliasesByModule.get(ownModule) ??
722
+ aliasesByModule.set(ownModule, new AliasResolver()).get(ownModule)!);
723
+ resolver.registerImport(alias, targetModule, exportedKinds);
724
+ for (const [suffix, canonical] of Object.entries(reExportedKinds)) {
725
+ resolver.registerKindReExport(alias, suffix, canonical);
718
726
  }
719
727
  }
720
728
  }
@@ -774,6 +782,15 @@ export class StaticAnalyzer {
774
782
  defs.register(normalized);
775
783
  }
776
784
 
785
+ // Reference-form validation — enforce `!ref` as the only reference shape.
786
+ // Runs on the RAW manifests, BEFORE inline extraction and sentinel
787
+ // resolution, while an author-written `{kind, name}` is still
788
+ // distinguishable from the resolver's own substitution (after Phase 2/2.5
789
+ // they are the same object).
790
+ if (!options?.skipValidation) {
791
+ diagnostics.push(...validateReferenceForms(manifests, defs, aliases, aliasesByModule));
792
+ }
793
+
777
794
  // Phase 2: extract inline resources from x-telo-ref slots into first-class manifests
778
795
  const allManifests = normalizeInlineResources(manifests, defs, aliases, aliasesByModule);
779
796
 
@@ -781,7 +798,7 @@ export class StaticAnalyzer {
781
798
  // {kind, name} objects so downstream phases (validation, dependency graph,
782
799
  // kernel controllers) see a uniform shape. Runs after normalize so both
783
800
  // original and inline-extracted manifests have their sentinels resolved.
784
- resolveRefSentinels(allManifests, defs, aliases, aliasesByModule);
801
+ resolveRefSentinels(allManifests, aliases, aliasesByModule);
785
802
 
786
803
  // Trusted-input fast path: when the caller has already attested that
787
804
  // this exact manifest set passes analysis (e.g. via the kernel's
@@ -854,6 +871,26 @@ export class StaticAnalyzer {
854
871
  }
855
872
  }
856
873
  }
874
+ // `exports.resources` entries are plain names: `Db` (local) or `Alias.Name` (re-export),
875
+ // mirroring `exports.kinds`. The `!ref` tag is not accepted here — a `!ref` parses to a
876
+ // sentinel object that the schema's CEL/ref exemption would silently pass, so reject any
877
+ // non-string entry with an actionable message instead.
878
+ const exportsResources = (m as Record<string, any>).exports?.resources;
879
+ if (Array.isArray(exportsResources)) {
880
+ for (let i = 0; i < exportsResources.length; i++) {
881
+ if (typeof exportsResources[i] === "string") continue;
882
+ diagnostics.push({
883
+ severity: DiagnosticSeverity.Error,
884
+ code: "INVALID_EXPORT",
885
+ source: SOURCE,
886
+ message:
887
+ `Telo.Library exports.resources[${i}]: write the exported name as a plain string — ` +
888
+ `'Name' to export a local instance, or 'Alias.Name' to re-export an imported one. ` +
889
+ `The '!ref' tag is not allowed in exports.resources.`,
890
+ data: { resource, filePath, path: `exports.resources.${i}` },
891
+ });
892
+ }
893
+ }
857
894
  }
858
895
 
859
896
  // Build typed kernel globals schema so x-telo-context chain validation
@@ -933,8 +970,27 @@ export class StaticAnalyzer {
933
970
  },
934
971
  }
935
972
  : definition.schema;
936
- // Phase 1: CEL type checking — walk data+schema together, check env.check() return types
937
- const baseTypedEnv = buildTypedCelEnvironment(this.celEnv, m, undefined, moduleManifest);
973
+ // Phase 1: CEL type checking — walk data+schema together, check env.check() return types.
974
+ // A Telo.Import's variables/secrets are a config-only contract evaluated against the
975
+ // IMPORTING module's scope, so type them from the owning module doc (matched by
976
+ // `metadata.module`) and drop `resources`/`env` so referencing them is an error. A
977
+ // library's own internal import is validated against that library in the library's
978
+ // standalone analysis; in this flattened app pass the library doc is absent, so the
979
+ // importer is undefined here and variables/secrets fall back to a permissive `map`
980
+ // (no false positives) while resources/env stay rejected.
981
+ const importerModule =
982
+ m.kind === "Telo.Import"
983
+ ? allManifests.find(
984
+ (mm) =>
985
+ (mm.kind === "Telo.Application" || mm.kind === "Telo.Library") &&
986
+ (mm.metadata as { name?: string } | undefined)?.name ===
987
+ (m.metadata as { module?: string } | undefined)?.module,
988
+ )
989
+ : undefined;
990
+ const baseTypedEnv =
991
+ m.kind === "Telo.Import"
992
+ ? buildImportInputCelEnvironment(this.celEnv, importerModule)
993
+ : buildTypedCelEnvironment(this.celEnv, m, undefined, moduleManifest);
938
994
  const celIssues = collectCelTypeIssues(
939
995
  m,
940
996
  schema,
@@ -1286,13 +1342,7 @@ export class StaticAnalyzer {
1286
1342
  // Resolve !ref sentinels after normalize so both the original and
1287
1343
  // inline-extracted manifests get their refs canonicalized to
1288
1344
  // {kind, name} for the kernel that consumes this output.
1289
- resolveRefSentinels(
1290
- normalized,
1291
- ctx.definitions!,
1292
- ctx.aliases,
1293
- ctx.aliasesByModule,
1294
- crossModuleTargets ?? [],
1295
- );
1345
+ resolveRefSentinels(normalized, ctx.aliases, ctx.aliasesByModule, crossModuleTargets ?? []);
1296
1346
  return normalized;
1297
1347
  }
1298
1348
 
package/src/builtins.ts CHANGED
@@ -236,7 +236,7 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
236
236
  },
237
237
  // Gated reference: run() a Runnable/Service only when the
238
238
  // `when` CEL guard holds. Discriminated by the `ref` key. `ref`
239
- // is a bare name or a resolved `!ref` (`{ kind, name }`).
239
+ // is a `!ref` that resolves to the `{ kind, name }` shape below.
240
240
  {
241
241
  type: "object",
242
242
  required: ["ref"],
@@ -264,12 +264,11 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
264
264
  // with an optional `name` (for steps.<name>.result plumbing),
265
265
  // `when` guard, and `inputs`. Discriminated by the `invoke` key.
266
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.
267
+ // reach for Run.Sequence. `invoke` is ref-only: a `!ref` that
268
+ // resolves to the `{ kind, name }` shape below. Requiring `name`
269
+ // rejects an inline `{ kind }` definition (no name) at analysis
270
+ // instead of failing at boot with an undefined resource name. The
271
+ // Invocable/Runnable kind set mirrors Run.Sequence invoke steps.
273
272
  {
274
273
  type: "object",
275
274
  required: ["invoke"],
@@ -456,8 +455,10 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
456
455
  type: "object",
457
456
  properties: {
458
457
  kinds: { type: "array", items: { type: "string" } },
459
- // `variables` / `secrets` are reserved on the resources.<Alias> value-flow
460
- // surface, so a library may not export instances under those names.
458
+ // An entry is a bare name (`Db`, a locally-owned export) or a dotted `Alias.Name`
459
+ // (re-export of the instance reached via this library's import aliased `Alias`,
460
+ // under the name `Name`) — mirroring `exports.kinds`. `variables` / `secrets` are
461
+ // reserved on the resources.<Alias> value-flow surface, so they may not be exported.
461
462
  resources: {
462
463
  type: "array",
463
464
  items: { type: "string", not: { enum: ["variables", "secrets"] } },
@@ -102,3 +102,60 @@ export function buildTypedCelEnvironment(
102
102
  return baseEnv.clone();
103
103
  }
104
104
  }
105
+
106
+ /** Register a `variables`/`secrets` namespace typed from a module doc's schema map
107
+ * (`{ name: <schema>, … }`), falling back to dyn `map` when absent or untyped. */
108
+ function registerConfigNamespace(
109
+ env: Environment,
110
+ block: unknown,
111
+ name: "variables" | "secrets",
112
+ ): void {
113
+ if (block !== null && typeof block === "object" && !Array.isArray(block)) {
114
+ const entries = Object.entries(block as Record<string, unknown>).filter(
115
+ ([, v]) => v !== null && typeof v === "object" && !Array.isArray(v),
116
+ );
117
+ if (entries.length > 0) {
118
+ const schema: Record<string, string> = {};
119
+ for (const [k, v] of entries) schema[k] = jsonSchemaToCelType(v as Record<string, any>);
120
+ (env as any).registerVariable({ name, schema });
121
+ return;
122
+ }
123
+ }
124
+ env.registerVariable(name, "map");
125
+ }
126
+
127
+ /** CEL environment for the `variables:`/`secrets:` expressions on a `Telo.Import`.
128
+ *
129
+ * Import inputs are a config-only contract: their expressions are evaluated
130
+ * against the IMPORTING module's `variables`/`secrets`, never the import's own
131
+ * values map (the bug) nor the imported child's. `resources`, `env`, and `ports`
132
+ * are registered as empty typed objects, so referencing them is a "No such key"
133
+ * error that steers authors to a typed `variables` entry. */
134
+ export function buildImportInputCelEnvironment(
135
+ baseEnv: Environment,
136
+ moduleManifest: ResourceManifest | undefined,
137
+ ): Environment {
138
+ const env = baseEnv.clone();
139
+ for (const brand of Object.keys(VALUE_BRAND_BASE)) {
140
+ (env as any).registerType(brand, { fields: {} });
141
+ }
142
+ const mod = moduleManifest as Record<string, unknown> | undefined;
143
+ // Typing variables/secrets from the importer's schema can fail on a malformed
144
+ // schema; degrade those to permissive `map` if so — but never lose the
145
+ // resources/env/ports rejection registered below (the catch is scoped so a
146
+ // typing failure can't silently re-open the config-only contract).
147
+ try {
148
+ registerConfigNamespace(env, mod?.variables, "variables");
149
+ registerConfigNamespace(env, mod?.secrets, "secrets");
150
+ } catch {
151
+ env.registerVariable("variables", "map");
152
+ env.registerVariable("secrets", "map");
153
+ }
154
+ // Override the base env's dyn `resources`/`env`/`ports` with empty typed objects
155
+ // so any access (`resources.X`, `env.X`) is a "No such key" error — these
156
+ // surfaces are not part of the config-only import contract.
157
+ for (const name of ["resources", "env", "ports"]) {
158
+ (env as any).registerVariable({ name, schema: {} });
159
+ }
160
+ return env;
161
+ }