@telorun/analyzer 0.45.0 → 0.47.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 (37) hide show
  1. package/dist/alias-resolver.d.ts +45 -0
  2. package/dist/alias-resolver.d.ts.map +1 -1
  3. package/dist/alias-resolver.js +33 -0
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/analyzer.js +106 -5
  6. package/dist/builtins.d.ts.map +1 -1
  7. package/dist/builtins.js +3 -0
  8. package/dist/extends-resolution.d.ts +19 -2
  9. package/dist/extends-resolution.d.ts.map +1 -1
  10. package/dist/extends-resolution.js +25 -1
  11. package/dist/index.d.ts +5 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +3 -1
  14. package/dist/kernel-globals.d.ts +9 -1
  15. package/dist/kernel-globals.d.ts.map +1 -1
  16. package/dist/kernel-globals.js +24 -1
  17. package/dist/resolve-ref-sentinels.d.ts +13 -1
  18. package/dist/resolve-ref-sentinels.d.ts.map +1 -1
  19. package/dist/resolve-ref-sentinels.js +56 -5
  20. package/dist/validate-cel-context.d.ts.map +1 -1
  21. package/dist/validate-cel-context.js +17 -1
  22. package/dist/validate-observed-state.d.ts +98 -0
  23. package/dist/validate-observed-state.d.ts.map +1 -0
  24. package/dist/validate-observed-state.js +304 -0
  25. package/dist/validate-references.d.ts.map +1 -1
  26. package/dist/validate-references.js +8 -2
  27. package/package.json +2 -2
  28. package/src/alias-resolver.ts +58 -0
  29. package/src/analyzer.ts +126 -4
  30. package/src/builtins.ts +3 -0
  31. package/src/extends-resolution.ts +37 -3
  32. package/src/index.ts +13 -0
  33. package/src/kernel-globals.ts +20 -0
  34. package/src/resolve-ref-sentinels.ts +68 -4
  35. package/src/validate-cel-context.ts +20 -1
  36. package/src/validate-observed-state.ts +354 -0
  37. package/src/validate-references.ts +8 -2
package/src/analyzer.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
- import { canonicalTypeSchemaId } from "@telorun/sdk";
2
+ import { canonicalTypeSchemaId, OBSERVED_STATE_KEY } from "@telorun/sdk";
3
3
  import type { Environment } from "@marcbachmann/cel-js";
4
4
  import { defaultRegistry, isRefSentinel, isTaggedSentinel } from "@telorun/templating";
5
5
  import { AliasResolver, scopeResolverForModule } from "./alias-resolver.js";
@@ -14,6 +14,13 @@ import { DefinitionRegistry } from "./definition-registry.js";
14
14
  import { effectiveAuthorSchema } from "./extends-resolution.js";
15
15
  import { buildDependencyGraph, formatCycle } from "./dependency-graph.js";
16
16
  import { buildKernelGlobalsSchema, mergeKernelGlobalsIntoContext } from "./kernel-globals.js";
17
+ import {
18
+ buildObservedStateIndex,
19
+ buildObservedStateResourcesSchema,
20
+ collectRunReachableNames,
21
+ observedStateRead,
22
+ validateObservedStateDeclarations,
23
+ } from "./validate-observed-state.js";
17
24
  import { computeSuggestKind } from "./kind-suggest.js";
18
25
  import { visitManifest } from "./manifest-visitor.js";
19
26
  import { isModuleKind } from "./module-kinds.js";
@@ -33,6 +40,7 @@ import {
33
40
  import { collectValueSchemaIssues } from "./validate-value-schema.js";
34
41
  import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisOptions } from "./types.js";
35
42
  import {
43
+ extractAccessChains,
36
44
  extractCelRegionScopes,
37
45
  extractContextsFromSchema,
38
46
  getManifestItem,
@@ -717,6 +725,16 @@ function errorContextForPath(
717
725
  return best?.schema;
718
726
  }
719
727
 
728
+ /** Member-access chains in a CEL expression, or none when it doesn't parse.
729
+ * Best-effort: a syntax error is reported by the engine pass, not here. */
730
+ function celAccessChains(env: Environment, expr: string): string[][] {
731
+ try {
732
+ return extractAccessChains(env.parse(expr).ast);
733
+ } catch {
734
+ return [];
735
+ }
736
+ }
737
+
720
738
  const CEL_PURE_RE = /^\s*\$\{\{[^}]*\}\}\s*$/;
721
739
  const CEL_EXPR_RE = /\$\{\{\s*([^}]+?)\s*\}\}/;
722
740
 
@@ -1135,7 +1153,7 @@ export class StaticAnalyzer {
1135
1153
  // {kind, name} objects so downstream phases (validation, dependency graph,
1136
1154
  // kernel controllers) see a uniform shape. Runs after normalize so both
1137
1155
  // original and inline-extracted manifests have their sentinels resolved.
1138
- resolveRefSentinels(allManifests, aliases, aliasesByModule);
1156
+ resolveRefSentinels(allManifests, aliases, aliasesByModule, [], defs);
1139
1157
 
1140
1158
  // Phase 2.6: register each named `Telo.Type` resource's schema under its
1141
1159
  // canonical module-scoped id (`telo://<module>/<name>`), validate
@@ -1280,9 +1298,38 @@ export class StaticAnalyzer {
1280
1298
  }
1281
1299
  }
1282
1300
 
1301
+ // What each resource reports while running (`status:`), and which resources
1302
+ // some slot can actually start. Both feed the observed-state checks below.
1303
+ // A RESOURCE's kind is written in the module that declares it and is never
1304
+ // canonicalized (unlike a definition's `extends`, normalized at registration
1305
+ // above), so an exported instance's `kind: Self.X` only resolves in its own
1306
+ // library's scope.
1307
+ const moduleScopes = { aliasesByModule, rootModules };
1308
+
1309
+ const observedState = buildObservedStateIndex(allManifests, defs, aliases, moduleScopes);
1310
+ const reportsObservedState = [...observedState.values()].some((r) => r.status);
1311
+ const runReachable = reportsObservedState
1312
+ ? collectRunReachableNames(allManifests, defs, aliases)
1313
+ : new Set<string>();
1314
+
1283
1315
  // Build typed kernel globals schema so x-telo-context chain validation
1284
1316
  // recognises variables, secrets, resources, env automatically
1285
- const kernelGlobals = buildKernelGlobalsSchema(allManifests);
1317
+ const kernelGlobals = buildKernelGlobalsSchema(allManifests, observedState);
1318
+
1319
+ // Fallback context for CEL in a slot with no `x-telo-context` annotation:
1320
+ // everything stays open except the typed `.status` nodes, so unknown-field
1321
+ // checking reaches observed state everywhere without newly rejecting any
1322
+ // read that passes today.
1323
+ const observedStateContext: Record<string, any> | null =
1324
+ reportsObservedState
1325
+ ? {
1326
+ type: "object",
1327
+ additionalProperties: true,
1328
+ properties: {
1329
+ resources: buildObservedStateResourcesSchema(observedState, true),
1330
+ },
1331
+ }
1332
+ : null;
1286
1333
 
1287
1334
  // The module doc (Application/Library) carries the Application-only `ports`
1288
1335
  // namespace; threaded into per-resource CEL typing so `${{ ports.X }}`
@@ -1600,6 +1647,9 @@ export class StaticAnalyzer {
1600
1647
  // `x-telo-step-context` / `x-telo-error-context` scopes. A `!cel` outside
1601
1648
  // every region is read as a literal — the runtime never evaluates it.
1602
1649
  let celEvalPaths: string[] = [];
1650
+ // The compile half alone: a field that resolves at startup, where observed
1651
+ // state cannot exist yet.
1652
+ let celCompilePaths: string[] = [];
1603
1653
  let celRegionScopes: string[] = [];
1604
1654
  let celRuleApplies = false;
1605
1655
 
@@ -1639,9 +1689,14 @@ export class StaticAnalyzer {
1639
1689
  ? buildEvalPaths(capabilityDef.schema as Record<string, any>)
1640
1690
  : { compile: [], runtime: [] };
1641
1691
  celEvalPaths = [...own.compile, ...own.runtime, ...parent.compile, ...parent.runtime];
1692
+ // A `Telo.Provider`'s fields are implicitly compile-eval — the
1693
+ // capability abstract carries the root annotation — so its reads are
1694
+ // covered here without the provider restating anything.
1695
+ celCompilePaths = [...own.compile, ...parent.compile];
1642
1696
  celRegionScopes = extractCelRegionScopes(ownSchema);
1643
1697
  } else {
1644
1698
  celEvalPaths = [];
1699
+ celCompilePaths = [];
1645
1700
  celRegionScopes = [];
1646
1701
  }
1647
1702
  },
@@ -1674,6 +1729,44 @@ export class StaticAnalyzer {
1674
1729
  return;
1675
1730
  }
1676
1731
 
1732
+ // Observed state exists only while the application runs, so a path
1733
+ // through `.status` is illegal in a field that resolves at startup —
1734
+ // and a resource nothing can start reports nothing, ever. Both are
1735
+ // decided from the expression and the manifest alone.
1736
+ if (reportsObservedState && engineName === "cel" && expr.includes(OBSERVED_STATE_KEY)) {
1737
+ for (const chain of celAccessChains(this.celEnv, expr)) {
1738
+ const read = observedStateRead(chain);
1739
+ if (!read) continue;
1740
+ // An import's exported instance is indexed under `<Alias>.<name>`,
1741
+ // the two-level shape it publishes under, so a cross-module read
1742
+ // is checked exactly like a local one.
1743
+ const reported = observedState.get(
1744
+ read.alias ? `${read.alias}.${read.name}` : read.name,
1745
+ );
1746
+
1747
+ if (celRuleApplies && evalPathsCover(celCompilePaths, path)) {
1748
+ diagnostics.push({
1749
+ severity: DiagnosticSeverity.Error,
1750
+ code: "OBSERVED_STATE_IN_STARTUP_FIELD",
1751
+ source: SOURCE,
1752
+ message: `${m.kind}/${resource.name}: '${path}' is resolved once at startup, so '${chain.join(".")}' does not exist yet — '${read.name}' reports it only while the application is running. Read reported values where the call happens: a step's inputs:, a request's url, a route handler, or a returns: expression.`,
1753
+ data: { resource, filePath, path },
1754
+ });
1755
+ continue;
1756
+ }
1757
+
1758
+ if (reported && !runReachable.has(read.name)) {
1759
+ diagnostics.push({
1760
+ severity: DiagnosticSeverity.Error,
1761
+ code: "OBSERVED_STATE_NEVER_RUN",
1762
+ source: SOURCE,
1763
+ message: `${m.kind}/${resource.name}: '${read.name}' reports '${read.field ?? OBSERVED_STATE_KEY}' only while it is running, and nothing starts it. Add '!ref ${read.name}' to a targets: list, or invoke it from a step.`,
1764
+ data: { resource, filePath, path },
1765
+ });
1766
+ }
1767
+ }
1768
+ }
1769
+
1677
1770
  let matchedContext: Record<string, any> | undefined =
1678
1771
  e.contextSchema ?? celInvocationContext;
1679
1772
 
@@ -1723,6 +1816,12 @@ export class StaticAnalyzer {
1723
1816
  allManifests: allManifests as Record<string, any>[],
1724
1817
  });
1725
1818
  effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
1819
+ } else if (observedStateContext) {
1820
+ // No `x-telo-context` matched, so nothing was chain-validated here
1821
+ // before. Validate the observed-state segment alone rather than
1822
+ // merging the kernel globals, whose closed `variables` / `ports`
1823
+ // nodes would newly reject reads that pass today.
1824
+ effectiveContext = observedStateContext;
1726
1825
  }
1727
1826
 
1728
1827
  const engine = defaultRegistry().get(engineName);
@@ -1792,6 +1891,23 @@ export class StaticAnalyzer {
1792
1891
  // kind-instead-of-instance ref there is caught statically, not at runtime.
1793
1892
  diagnostics.push(...validateStepInvokeReferences(allManifests, defs, aliases));
1794
1893
 
1894
+ // `required:` inside a `status:` block — reported here rather than by the
1895
+ // AJV shape, which could only say "must NOT be valid" without naming the
1896
+ // rule or the fix.
1897
+ for (const issue of validateObservedStateDeclarations(allManifests)) {
1898
+ diagnostics.push({
1899
+ severity: DiagnosticSeverity.Error,
1900
+ code: "OBSERVED_STATE_REQUIRED_FORBIDDEN",
1901
+ source: SOURCE,
1902
+ message: issue.message,
1903
+ data: {
1904
+ resource: { kind: issue.kind, name: issue.name },
1905
+ filePath: issue.filePath,
1906
+ path: "status.required",
1907
+ },
1908
+ });
1909
+ }
1910
+
1795
1911
  // Validate `extends` fields and flag legacy `capability: <UserAbstract>` overload.
1796
1912
  diagnostics.push(...validateExtends(allManifests, defs, aliases));
1797
1913
 
@@ -1841,7 +1957,13 @@ export class StaticAnalyzer {
1841
1957
  // Resolve !ref sentinels after normalize so both the original and
1842
1958
  // inline-extracted manifests get their refs canonicalized to
1843
1959
  // {kind, name} for the kernel that consumes this output.
1844
- resolveRefSentinels(normalized, ctx.aliases, ctx.aliasesByModule, crossModuleTargets ?? []);
1960
+ resolveRefSentinels(
1961
+ normalized,
1962
+ ctx.aliases,
1963
+ ctx.aliasesByModule,
1964
+ crossModuleTargets ?? [],
1965
+ ctx.definitions!,
1966
+ );
1845
1967
  // Canonicalize import-scoped schema `$ref`s (`telo://Self|Alias/Type`) so the
1846
1968
  // kernel that executes this output compiles inputs/outputs against the same
1847
1969
  // ids the type controllers register their schemas under.
package/src/builtins.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ResourceDefinition } from "@telorun/sdk";
2
+ import { OBSERVED_STATE_SCHEMA } from "./validate-observed-state.js";
2
3
 
3
4
  /** Descriptive provenance a module declares about itself, shared by
4
5
  * `Telo.Application` and `Telo.Library`.
@@ -186,6 +187,7 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
186
187
  },
187
188
  capability: { type: "string" },
188
189
  schema: { type: "object", additionalProperties: true },
190
+ status: OBSERVED_STATE_SCHEMA,
189
191
  },
190
192
  required: ["metadata"],
191
193
  // Telo.Abstract is an extension point by design — it must accept forward-compatible
@@ -214,6 +216,7 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
214
216
  type: "object",
215
217
  additionalProperties: true,
216
218
  properties: {
219
+ status: OBSERVED_STATE_SCHEMA,
217
220
  resources: {
218
221
  type: "array",
219
222
  items: {
@@ -2,8 +2,17 @@ import type { ResourceDefinition } from "@telorun/sdk";
2
2
  import { mergeTypeSchemas } from "@telorun/sdk";
3
3
 
4
4
  /** Resolves a kind string (canonical or alias form, depending on the caller's
5
- * registry) to its `Telo.Definition` / `Telo.Abstract`, or undefined. */
6
- export type DefResolver = (kind: string) => ResourceDefinition | undefined;
5
+ * registry) to its `Telo.Definition` / `Telo.Abstract`, or undefined.
6
+ *
7
+ * `from` is the definition the kind was read off. An `extends` alias belongs to
8
+ * the file that DECLARES the definition, not to whoever is reading it, so a
9
+ * resolver that walks an inheritance chain across module boundaries must
10
+ * re-scope at each hop — `from.metadata.module` is what it scopes to. Resolvers
11
+ * that operate in a single scope ignore the parameter. */
12
+ export type DefResolver = (
13
+ kind: string,
14
+ from?: ResourceDefinition,
15
+ ) => ResourceDefinition | undefined;
7
16
 
8
17
  /** The template-body / controller fields a definition may carry. Kept local
9
18
  * because `ResourceDefinition` intentionally types only the stable surface;
@@ -19,6 +28,7 @@ interface DefinitionBody {
19
28
  resources?: unknown[];
20
29
  base?: Record<string, unknown>;
21
30
  schema?: Record<string, any>;
31
+ status?: Record<string, any>;
22
32
  }
23
33
 
24
34
  const body = (def: ResourceDefinition | undefined): DefinitionBody =>
@@ -32,7 +42,7 @@ export function resolveParent(
32
42
  ): ResourceDefinition | undefined {
33
43
  const ext = body(def).extends;
34
44
  if (typeof ext !== "string" || ext.length === 0) return undefined;
35
- return resolve(ext);
45
+ return resolve(ext, def);
36
46
  }
37
47
 
38
48
  /** The `extends` ancestor chain, nearest-first, excluding `def` itself.
@@ -122,3 +132,27 @@ export function effectiveAuthorSchema(
122
132
  const parentSchema = effectiveAuthorSchema(parent, resolve);
123
133
  return mergeTypeSchemas([parentSchema, own]) as Record<string, any>;
124
134
  }
135
+
136
+ /** The observed state a kind reports (`status:`), folded through `extends`:
137
+ * - with `base:` present → the **parent's** effective status unchanged; the
138
+ * child delegates to the parent's controller and *is* a parent instance, so
139
+ * it publishes exactly what the parent publishes.
140
+ * - without `base:` but with `extends` → `merge(parent-effective, own)`, so a
141
+ * contract can mandate what its implementations report and an implementation
142
+ * can add to it.
143
+ * - no `extends` → the own block unchanged.
144
+ * Undefined when nothing in the chain declares one — the signal that the kind
145
+ * has not opted into typed `.status` reads. */
146
+ export function effectiveStatusSchema(
147
+ def: ResourceDefinition | undefined,
148
+ resolve: DefResolver,
149
+ ): Record<string, any> | undefined {
150
+ const own = body(def).status;
151
+ const parent = resolveParent(def, resolve);
152
+ if (!parent) return own;
153
+ const parentStatus = effectiveStatusSchema(parent, resolve);
154
+ if (body(def).base) return parentStatus;
155
+ if (!parentStatus) return own;
156
+ if (!own) return parentStatus;
157
+ return mergeTypeSchemas([parentStatus, own]) as Record<string, any>;
158
+ }
package/src/index.ts CHANGED
@@ -24,10 +24,23 @@ export {
24
24
  type ReExportSpec,
25
25
  } from "./flatten-for-analyzer.js";
26
26
  export { buildEvalPaths, evalPathCovers } from "./eval-paths.js";
27
+ export {
28
+ applyObservedStateNode,
29
+ buildObservedStateIndex,
30
+ buildObservedStateResourcesSchema,
31
+ collectRunReachableNames,
32
+ observedStateRead,
33
+ validateObservedStateDeclarations,
34
+ OBSERVED_STATE_SCHEMA,
35
+ } from "./validate-observed-state.js";
36
+ export type { AnalyzedResource, ObservedStateRead } from "./validate-observed-state.js";
37
+ export { moduleScopedDefResolver, scopeResolverForModule } from "./alias-resolver.js";
38
+ export type { ModuleScopes } from "./alias-resolver.js";
27
39
  export {
28
40
  ancestorChain,
29
41
  controllerBearingAncestor,
30
42
  effectiveAuthorSchema,
43
+ effectiveStatusSchema,
31
44
  hasOwnControllerOrTemplate,
32
45
  inheritedCapability,
33
46
  isInheritedDelegation,
@@ -1,5 +1,6 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
2
  import { residualEntrySchemaMap } from "./residual-schema.js";
3
+ import { applyObservedStateNode } from "./validate-observed-state.js";
3
4
 
4
5
  /**
5
6
  * Kernel global names available in every CEL evaluation context at runtime.
@@ -36,6 +37,11 @@ const SYSTEM_KINDS = new Set([
36
37
  */
37
38
  export function buildKernelGlobalsSchema(
38
39
  manifests: ResourceManifest[],
40
+ /** Every resource a CEL read can name, including scope-declared ones (see
41
+ * `buildObservedStateIndex`). Kinds that declare a `status:` get a typed,
42
+ * closed `status` node; every other resource node stays open, so no flat read
43
+ * that passes today can start failing. */
44
+ resources?: ReadonlyMap<string, { kind: string; status?: Record<string, any> }>,
39
45
  ): Record<string, any> {
40
46
  const moduleManifest =
41
47
  (manifests.find((m) => m.kind === "Telo.Application") as
@@ -55,6 +61,20 @@ export function buildKernelGlobalsSchema(
55
61
  resourceProps[name] = { type: "object", additionalProperties: true };
56
62
  }
57
63
  }
64
+ // Scope-declared resources (a `Run.Sequence`'s `with:`) publish like any other
65
+ // now, so their names resolve too — inside the scope's regions, which is where
66
+ // the only expressions that can name them live.
67
+ for (const [key, entry] of resources ?? []) {
68
+ if (key.includes(".")) continue;
69
+ resourceProps[key] ??= { type: "object", additionalProperties: true };
70
+ if (entry.status) applyObservedStateNode(resourceProps, key, entry.status);
71
+ }
72
+ // Imports' exported instances publish two levels deep (`resources.<Alias>.<name>`);
73
+ // the alias node stays open so its other keys keep resolving.
74
+ for (const [key, entry] of resources ?? []) {
75
+ if (!key.includes(".") || !entry.status) continue;
76
+ applyObservedStateNode(resourceProps, key, entry.status);
77
+ }
58
78
 
59
79
  return {
60
80
  type: "object",
@@ -1,8 +1,22 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
2
  import { isRefSentinel, isTaggedSentinel } from "@telorun/templating";
3
3
  import type { AliasResolver } from "./alias-resolver.js";
4
+ import {
5
+ isScopeEntry,
6
+ resolveFieldEntries,
7
+ type ReferenceFieldMap,
8
+ } from "./reference-field-map.js";
4
9
  import { REF_RESOLUTION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
5
10
 
11
+ /** The slice of the definition registry this pass needs: a kind's field map, from
12
+ * which the `x-telo-scope` slots are read. */
13
+ export interface ScopeFieldMapSource {
14
+ getFieldMapForKind(
15
+ kind: string,
16
+ aliases?: { resolveKind(k: string): string | undefined },
17
+ ): ReferenceFieldMap | undefined;
18
+ }
19
+
6
20
  /** Resolved ref shape written in place of a `!ref` sentinel. `alias` is set only for
7
21
  * cross-module references (resolved into an imported library's exported instance). */
8
22
  type ResolvedRef = { kind: string; name: string; alias?: string };
@@ -52,6 +66,10 @@ export function resolveRefSentinels(
52
66
  // pass — which loads the entry module only — can still resolve `!ref Alias.name` against
53
67
  // imported libraries' exported instances.
54
68
  crossModuleTargets: ResourceManifest[] = [],
69
+ /** Supplies each kind's `x-telo-scope` slots. Without it a scoped name cannot be
70
+ * told from a module-level one, and a shadowed `!ref` resolves to the resource
71
+ * it shadows — so both call sites pass it. */
72
+ defs?: ScopeFieldMapSource,
55
73
  ): void {
56
74
  const moduleOf = (r: ResourceManifest): string | undefined =>
57
75
  (r.metadata as { module?: string } | undefined)?.module;
@@ -109,21 +127,67 @@ export function resolveRefSentinels(
109
127
  return undefined;
110
128
  };
111
129
 
130
+ /** Names a resource declares in its own execution scopes, read from the kind's
131
+ * `x-telo-scope` slots — the analyzer's single definition of "scope", shared
132
+ * with `manifest-visitor`. Inferring it structurally instead (any array of
133
+ * named inline manifests) would give scope-local shadowing to the first kind
134
+ * that happens to carry such an array without asking for it, and this pass is
135
+ * shared with the kernel, so the guess would be baked into the runtime tree
136
+ * rather than merely reported. */
137
+ const declaredInScopes = (
138
+ resource: ResourceManifest,
139
+ ): Map<string, ResourceManifest> | undefined => {
140
+ const fieldMap = defs?.getFieldMapForKind(resource.kind, aliases);
141
+ if (!fieldMap) return undefined;
142
+ let declared: Map<string, ResourceManifest> | undefined;
143
+ for (const [fieldPath, entry] of fieldMap) {
144
+ if (!isScopeEntry(entry)) continue;
145
+ for (const { value } of resolveFieldEntries(resource, fieldPath)) {
146
+ for (const element of Array.isArray(value) ? value : [value]) {
147
+ if (!element || typeof element !== "object" || Array.isArray(element)) continue;
148
+ const manifest = element as ResourceManifest;
149
+ const name = (manifest.metadata as { name?: string } | undefined)?.name;
150
+ if (typeof manifest.kind === "string" && typeof name === "string") {
151
+ (declared ??= new Map()).set(name, manifest);
152
+ }
153
+ }
154
+ }
155
+ }
156
+ return declared;
157
+ };
158
+
112
159
  // Resolve every `!ref` sentinel in the tree; leave opaque tagged / precompiled
113
160
  // nodes (e.g. `!cel`) untouched and don't descend into them.
114
- const walk = (value: unknown): unknown => {
161
+ //
162
+ // `scoped` carries the names the enclosing resource declares in its `x-telo-scope`
163
+ // slots, and they SHADOW the module-level ones — the order the runtime resolves
164
+ // in. Baking the module-level kind into a shadowed reference would label traces
165
+ // and `getRefIdentity` with a resource that never runs.
166
+ const walk = (value: unknown, scoped?: Map<string, ResourceManifest>): unknown => {
115
167
  if (isRefSentinel(value)) {
116
- return resolveTarget(value.source) ?? value;
168
+ const source = value.source;
169
+ const bare = source.indexOf(".") === -1;
170
+ const shadow = bare ? scoped?.get(source) : undefined;
171
+ if (shadow) return { kind: shadow.kind as string, name: source };
172
+ return resolveTarget(source) ?? value;
117
173
  }
118
174
  if (value === null || typeof value !== "object") return value;
119
175
  if (isTaggedSentinel(value)) return value;
120
176
  if ((value as { __compiled?: unknown }).__compiled) return value;
121
177
  if (Array.isArray(value)) {
122
- for (let i = 0; i < value.length; i++) value[i] = walk(value[i]);
178
+ for (let i = 0; i < value.length; i++) value[i] = walk(value[i], scoped);
123
179
  return value;
124
180
  }
125
181
  const obj = value as Record<string, unknown>;
126
- for (const key of Object.keys(obj)) obj[key] = walk(obj[key]);
182
+ // A nested inline resource may declare scopes of its own (a `Run.Sequence`
183
+ // inside another sequence's `with:`). Collected before descending, so the
184
+ // declarations are visible to every region of the resource that declares
185
+ // them — a sequence's `with:` names resolve in its `targets:` and `steps:`
186
+ // alike, not only inside `with:` itself.
187
+ const declared =
188
+ typeof obj.kind === "string" ? declaredInScopes(obj as ResourceManifest) : undefined;
189
+ const inner = declared ? new Map([...(scoped ?? new Map()), ...declared]) : scoped;
190
+ for (const key of Object.keys(obj)) obj[key] = walk(obj[key], inner);
127
191
  return value;
128
192
  };
129
193
 
@@ -323,18 +323,37 @@ export function resolveContextAnnotations(
323
323
  typeof ref.name === "string" &&
324
324
  subpath
325
325
  ) {
326
+ const segments = subpath.split("/");
326
327
  const refManifest = allManifests.find(
327
328
  (m) => m.kind === ref.kind && (m.metadata as any)?.name === ref.name,
328
329
  ) as Record<string, any> | undefined;
329
330
  if (refManifest) {
330
331
  const resolved = resolveTypeFieldToSchema(
331
- navigatePath(refManifest, subpath.split("/")) as unknown,
332
+ navigatePath(refManifest, segments) as unknown,
332
333
  allManifests,
333
334
  );
334
335
  if (resolved && typeof resolved === "object") {
335
336
  return resolved;
336
337
  }
337
338
  }
339
+ // The instance declares nothing, so fall back to its KIND's declaration —
340
+ // the same layering `buildStepContextSchema` applies to `steps.<name>.result`,
341
+ // so a kind with one fixed output shape (declared once on its Telo.Definition)
342
+ // types the context, while a kind that exposes the field for per-instance
343
+ // narrowing keeps winning above.
344
+ if (defs) {
345
+ const canonical = aliases?.resolveKind(ref.kind) ?? ref.kind;
346
+ const def = defs.resolve(canonical) as Record<string, unknown> | undefined;
347
+ if (def) {
348
+ const resolved = resolveTypeFieldToSchema(
349
+ navigatePath(def, segments) as unknown,
350
+ allManifests,
351
+ );
352
+ if (resolved && typeof resolved === "object") {
353
+ return resolved;
354
+ }
355
+ }
356
+ }
338
357
  }
339
358
  // Fallback: open schema (no false errors when outputType is not declared)
340
359
  return { ...schema, additionalProperties: true };