@telorun/analyzer 0.66.0 → 0.67.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 (76) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/analyzer.js +37 -2
  3. package/dist/builtins.d.ts.map +1 -1
  4. package/dist/builtins.js +67 -16
  5. package/dist/cel-scope.d.ts +8 -0
  6. package/dist/cel-scope.d.ts.map +1 -1
  7. package/dist/cel-scope.js +66 -8
  8. package/dist/definition-registry.d.ts +17 -0
  9. package/dist/definition-registry.d.ts.map +1 -1
  10. package/dist/definition-registry.js +35 -0
  11. package/dist/dependency-graph.d.ts.map +1 -1
  12. package/dist/dependency-graph.js +65 -0
  13. package/dist/flatten-for-analyzer.d.ts +36 -0
  14. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  15. package/dist/flatten-for-analyzer.js +103 -4
  16. package/dist/index.d.ts +4 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +3 -2
  19. package/dist/inline-imports.d.ts.map +1 -1
  20. package/dist/inline-imports.js +1 -0
  21. package/dist/manifest-visitor.d.ts +4 -0
  22. package/dist/manifest-visitor.d.ts.map +1 -1
  23. package/dist/manifest-visitor.js +28 -0
  24. package/dist/precompile.d.ts.map +1 -1
  25. package/dist/precompile.js +8 -0
  26. package/dist/resolve-ref-sentinels.d.ts.map +1 -1
  27. package/dist/resolve-ref-sentinels.js +14 -1
  28. package/dist/resource-input.d.ts +75 -0
  29. package/dist/resource-input.d.ts.map +1 -0
  30. package/dist/resource-input.js +90 -0
  31. package/dist/schema-projection.d.ts +13 -0
  32. package/dist/schema-projection.d.ts.map +1 -1
  33. package/dist/schema-projection.js +7 -0
  34. package/dist/system-kinds.d.ts +7 -2
  35. package/dist/system-kinds.d.ts.map +1 -1
  36. package/dist/system-kinds.js +7 -2
  37. package/dist/telo-version.d.ts +1 -1
  38. package/dist/telo-version.js +1 -1
  39. package/dist/template-body.d.ts +50 -0
  40. package/dist/template-body.d.ts.map +1 -0
  41. package/dist/template-body.js +58 -0
  42. package/dist/validate-cel-context.d.ts.map +1 -1
  43. package/dist/validate-cel-context.js +68 -8
  44. package/dist/validate-identifier-names.d.ts.map +1 -1
  45. package/dist/validate-identifier-names.js +17 -2
  46. package/dist/validate-references.d.ts +17 -0
  47. package/dist/validate-references.d.ts.map +1 -1
  48. package/dist/validate-references.js +68 -16
  49. package/dist/validate-resource-inputs.d.ts +35 -0
  50. package/dist/validate-resource-inputs.d.ts.map +1 -0
  51. package/dist/validate-resource-inputs.js +319 -0
  52. package/dist/validate-template-dispatch.d.ts +27 -0
  53. package/dist/validate-template-dispatch.d.ts.map +1 -0
  54. package/dist/validate-template-dispatch.js +95 -0
  55. package/package.json +3 -3
  56. package/src/analyzer.ts +45 -2
  57. package/src/builtins.ts +69 -16
  58. package/src/cel-scope.ts +90 -14
  59. package/src/definition-registry.ts +36 -0
  60. package/src/dependency-graph.ts +66 -0
  61. package/src/flatten-for-analyzer.ts +116 -3
  62. package/src/index.ts +12 -0
  63. package/src/inline-imports.ts +1 -0
  64. package/src/manifest-visitor.ts +33 -0
  65. package/src/precompile.ts +8 -0
  66. package/src/resolve-ref-sentinels.ts +12 -1
  67. package/src/resource-input.ts +132 -0
  68. package/src/schema-projection.ts +19 -0
  69. package/src/system-kinds.ts +7 -2
  70. package/src/telo-version.ts +1 -1
  71. package/src/template-body.ts +104 -0
  72. package/src/validate-cel-context.ts +67 -7
  73. package/src/validate-identifier-names.ts +18 -3
  74. package/src/validate-references.ts +70 -14
  75. package/src/validate-resource-inputs.ts +367 -0
  76. package/src/validate-template-dispatch.ts +99 -0
@@ -16,6 +16,8 @@ import {
16
16
  type RefFieldEntry,
17
17
  type SchemaFromFieldEntry,
18
18
  } from "./reference-field-map.js";
19
+ import type { ModuleScopes } from "./alias-resolver.js";
20
+ import { templateBodies, withTemplateSelf } from "./template-body.js";
19
21
  import { extractContextsFromSchema, pathMatchesScope } from "./validate-cel-context.js";
20
22
 
21
23
  /**
@@ -150,6 +152,10 @@ export interface VisitOptions {
150
152
  * Opt-in: the validators / dependency graph must NOT enable it (those refs
151
153
  * are runtime-resolved, not boot dependencies). */
152
154
  discoverNestedRefs?: boolean;
155
+ /** Root (consumer-owned) module names, paired with `aliasesByModule` so a
156
+ * nested kind inside an imported library's definition resolves through THAT
157
+ * library's aliases. */
158
+ rootModules?: ReadonlySet<string>;
153
159
  }
154
160
 
155
161
  /** Synthetic entry for a value-tree-discovered ref — these carry no declared
@@ -204,6 +210,15 @@ const pathUnderPrefix = (fieldPath: string, prefix: string): boolean =>
204
210
  fieldPath.startsWith(prefix + ".") ||
205
211
  fieldPath.startsWith(prefix + "[");
206
212
 
213
+ /** The per-declaring-module alias tables, when the caller threaded them. A
214
+ * nested kind inside an imported library's definition is written through THAT
215
+ * library's aliases. */
216
+ function moduleScopes(options: VisitOptions): ModuleScopes | undefined {
217
+ return options.aliasesByModule && options.rootModules
218
+ ? { aliasesByModule: options.aliasesByModule, rootModules: options.rootModules }
219
+ : undefined;
220
+ }
221
+
207
222
  export function visitManifest(
208
223
  resources: ResourceManifest[],
209
224
  registry: DefinitionRegistry,
@@ -367,6 +382,24 @@ export function visitManifest(
367
382
  // check simply stops existing.
368
383
  const authorSchema = registry.effectiveSchemaOf(definition);
369
384
  const contexts = authorSchema ? extractContextsFromSchema(authorSchema) : [];
385
+ // A `Telo.Definition`'s `resources:` entries are DECLARATIONS of another
386
+ // kind, so the CEL inside one belongs to THAT kind — its own
387
+ // `x-telo-context` regions, rebased under the entry's path. `self` is
388
+ // merged into each, since the nested kind knows nothing about the
389
+ // configuration its enclosing template was given. Without this a nested
390
+ // body saw one fixed permissive context: `inputs` and `item` undefined
391
+ // wherever the nested kind declares them, `error` offered everywhere.
392
+ for (const body of templateBodies(r, registry, aliases, moduleScopes(options))) {
393
+ const bodySchema = registry.effectiveSchemaOf(body.definition);
394
+ if (!bodySchema) continue;
395
+ for (const ctx of extractContextsFromSchema(bodySchema, body.scopePrefix)) {
396
+ contexts.push({ scope: ctx.scope, schema: withTemplateSelf(ctx.schema) });
397
+ }
398
+ }
399
+ // Longest scope wins, as `extractContextsFromSchema` already orders its
400
+ // own: a nested kind's region is deeper than the enclosing definition's
401
+ // blanket `$.resources[*]`, so it takes precedence.
402
+ contexts.sort((a, b) => b.scope.length - a.scope.length);
370
403
  walkCelExpressions(r, "", (expr, path, engineName, surface) => {
371
404
  let contextSchema: Record<string, any> | undefined;
372
405
  let matchedScope: string | undefined;
package/src/precompile.ts CHANGED
@@ -40,6 +40,14 @@ export function precompileDoc(doc: unknown, env: Environment): unknown {
40
40
  __compiled: true,
41
41
  engine: doc.engine,
42
42
  source: doc.source,
43
+ // The AST-derived root identifiers the engine computed, carried through
44
+ // rather than dropped. This rebuild is where EVERY tagged sentinel's
45
+ // compiled value is produced, so losing `refs` here lost them for every
46
+ // `!cel` in every manifest — leaving a consumer that asks what an
47
+ // expression READS (a template body deciding which nodes survive its
48
+ // `init()`) with nothing but the source text to scan, which cannot tell
49
+ // an identifier from a word inside a string literal.
50
+ ...(compiled.refs ? { refs: compiled.refs } : {}),
43
51
  call: compiled.call.bind(compiled),
44
52
  };
45
53
  }
@@ -193,7 +193,18 @@ export function resolveRefSentinels(
193
193
 
194
194
  for (const r of resources) {
195
195
  if (isForeign(r)) continue;
196
- if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
196
+ if (!r.metadata?.name || !r.kind) continue;
197
+ // A `Telo.Import` is import-time metadata, not a resource instance — except
198
+ // for its `resources:` block, which supplies the instances the target
199
+ // library declared it needs. Those are `!ref`s to the importer's OWN
200
+ // resources and resolve exactly like any other reference; nothing else on
201
+ // the document is a reference slot, so only that subtree is walked.
202
+ if (r.kind === "Telo.Import") {
203
+ const supplied = (r as Record<string, unknown>).resources;
204
+ if (supplied) (r as Record<string, unknown>).resources = walk(supplied);
205
+ continue;
206
+ }
207
+ if (SYSTEM_KINDS.has(r.kind)) continue;
197
208
  walk(r as Record<string, unknown>);
198
209
  }
199
210
  }
@@ -0,0 +1,132 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+
3
+ /**
4
+ * The `resources:` block on a `Telo.Library` — the instances a library requires
5
+ * from whoever imports it, beside the scalar `variables:` / `secrets:` blocks.
6
+ * Instances used to flow up only (`exports.resources`); this is the inward half.
7
+ *
8
+ * THE SINGLE READER of the block, in both halves (the analyzer's passes and the
9
+ * kernel's import controller), on the `ref-slot.ts` precedent: a boundary whose
10
+ * shape two runtimes must agree about is read in one place, so the next shape
11
+ * change is a one-file edit rather than four surfaces recognising a block by
12
+ * pattern-matching it.
13
+ *
14
+ * Browser-safe — no I/O, no Node built-ins.
15
+ */
16
+
17
+ /** One declared input. */
18
+ export interface ResourceInput {
19
+ /** The entry key. Inside the library the instance is named exactly as a
20
+ * locally declared resource: `!ref <name>` at a ref slot, `resources.<name>`
21
+ * in CEL. */
22
+ name: string;
23
+ /** The alias-qualified kind constraint, as written in the DECLARING library's
24
+ * own scope (`Sql.Connection`, `Self.Store`, `Telo.LogSink`). There is no
25
+ * `use:` here: the boundary is a dependency edge for init order whatever the
26
+ * library does with the instance, and the flattened application analysis
27
+ * drops the library doc, so an app-level claim about internal call sites is
28
+ * one nothing could check. */
29
+ kind: string;
30
+ description?: string;
31
+ }
32
+
33
+ /** Marker stamped on a synthesized kind-only declaration (see
34
+ * {@link injectedDeclarations}). Read through {@link isInjectedDeclaration} —
35
+ * never by testing the field. */
36
+ const INJECTED = "xTeloInjected";
37
+
38
+ /** Read a module document's `resources:` block. Returns `[]` for an
39
+ * `Telo.Application` (which has no such block), for a library that declares
40
+ * none, and for a malformed entry — the document's own schema validation
41
+ * reports the shape against the precise `resources.<name>` path. */
42
+ export function readResourceInputs(moduleDoc: unknown): ResourceInput[] {
43
+ const raw = (moduleDoc as { resources?: unknown } | undefined)?.resources;
44
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
45
+ const out: ResourceInput[] = [];
46
+ for (const [name, value] of Object.entries(raw as Record<string, unknown>)) {
47
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
48
+ const kind = (value as { kind?: unknown }).kind;
49
+ if (typeof kind !== "string" || kind.length === 0) continue;
50
+ const description = (value as { description?: unknown }).description;
51
+ out.push({
52
+ name,
53
+ kind,
54
+ ...(typeof description === "string" ? { description } : {}),
55
+ });
56
+ }
57
+ return out;
58
+ }
59
+
60
+ /** The values an import supplies for a target library's declared inputs, keyed
61
+ * by entry name. Read off a `Telo.Import` (authored or desugared from an
62
+ * `imports:` entry). Values are `!ref` sentinels before Phase 2.5 and
63
+ * `{kind, name}` after it — this reader does not interpret them. */
64
+ export function readSuppliedResources(importDoc: unknown): Record<string, unknown> {
65
+ const raw = (importDoc as { resources?: unknown } | undefined)?.resources;
66
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
67
+ return raw as Record<string, unknown>;
68
+ }
69
+
70
+ /** True when `manifest` is a synthesized kind-only declaration standing in for a
71
+ * library's `resources:` entry rather than a resource the author declared. */
72
+ export function isInjectedDeclaration(manifest: ResourceManifest | undefined): boolean {
73
+ return (manifest?.metadata as Record<string, unknown> | undefined)?.[INJECTED] === true;
74
+ }
75
+
76
+ /**
77
+ * Synthesize a kind-only declaration per `resources:` entry, in the declaring
78
+ * library's own scope.
79
+ *
80
+ * That model has to exist because a library's internals are validated in the
81
+ * library's own pass — the flattened application analysis drops the library doc
82
+ * — so with nothing behind `connection`, `!ref connection` would have nothing to
83
+ * resolve against.
84
+ *
85
+ * Kind-only is enough because it is already what a ref slot gets: a reading
86
+ * types its `status:` half from the kind, closed so a typo below it is
87
+ * `CEL_UNKNOWN_FIELD`, and leaves the flat half open, since no manifest declares
88
+ * what `snapshot()` returned. So `!ref connection` at a ref slot and
89
+ * `resources.connection.<field>` in CEL answer exactly as they do for a locally
90
+ * declared resource.
91
+ *
92
+ * The declaration is a stand-in, never an instantiation: the per-resource
93
+ * validation loop skips it (its kind is routinely an abstract, and its config is
94
+ * the importer's to supply), and the kernel's import controller filters it out
95
+ * of the manifests it registers, binding the borrowed instance under the name
96
+ * instead.
97
+ */
98
+ export function injectedDeclarations(
99
+ moduleDoc: ResourceManifest,
100
+ ownModule: string | undefined,
101
+ ): ResourceManifest[] {
102
+ const inputs = readResourceInputs(moduleDoc);
103
+ if (inputs.length === 0) return [];
104
+ const meta = moduleDoc.metadata as
105
+ | { source?: string; sourceLine?: number }
106
+ | undefined;
107
+ return inputs.map((input) => ({
108
+ kind: input.kind,
109
+ metadata: {
110
+ name: input.name,
111
+ ...(ownModule ? { module: ownModule } : {}),
112
+ source: meta?.source ?? "",
113
+ sourceLine: meta?.sourceLine ?? 0,
114
+ [INJECTED]: true,
115
+ },
116
+ })) as unknown as ResourceManifest[];
117
+ }
118
+
119
+ /** How many times a library is instantiated in one application. `isolated` —
120
+ * the default, and what every published module was written against — gives
121
+ * each import declaration its own child scope with its own instances;
122
+ * `shared` makes the library a singleton every import resolves to. */
123
+ export type LibraryLifecycle = "isolated" | "shared";
124
+
125
+ /** Read a module document's `lifecycle:`. `Telo.Application` has a field of the
126
+ * same name with a different default and no reader; this is the LIBRARY
127
+ * question only, so it answers `isolated` for anything else. */
128
+ export function readLibraryLifecycle(moduleDoc: unknown): LibraryLifecycle {
129
+ const doc = moduleDoc as { kind?: unknown; lifecycle?: unknown } | undefined;
130
+ if (doc?.kind !== "Telo.Library") return "isolated";
131
+ return doc.lifecycle === "shared" ? "shared" : "isolated";
132
+ }
@@ -35,7 +35,9 @@
35
35
  * derivation can reach.
36
36
  */
37
37
 
38
+ import type { ResourceManifest } from "@telorun/sdk";
38
39
  import { isRefSentinel } from "@telorun/templating";
40
+ import { isInjectedDeclaration } from "./resource-input.js";
39
41
 
40
42
  /** How a kind's entry collection projects to an object schema. */
41
43
  export interface SchemaProjection {
@@ -499,6 +501,15 @@ export type ProjectionFailure =
499
501
  readonly kind: string;
500
502
  readonly entries: string;
501
503
  }
504
+ /** The slot names a resource the module does not DECLARE — a library's
505
+ * `resources:` input, standing in for an instance its importer supplies. A
506
+ * projection is DECLARATION-derived, so it cannot be answered here at all;
507
+ * the stand-in has no entries, and reporting that would tell the library
508
+ * author their block is wrong when it is correct. Resolution moves to the
509
+ * injection site, where the real declaration is. Carried as its own reason
510
+ * rather than as `no-entries` so a consumer can tell "unanswerable here"
511
+ * from "answered, and empty". */
512
+ | { readonly reason: "injected"; readonly pointer: string; readonly name: string }
502
513
  /** An ENTRY of the projected declaration references a shape that could not be
503
514
  * read. Reported rather than dropped: the entry would silently vanish from
504
515
  * the projected row, so a consumer naming it would be told the property does
@@ -523,6 +534,9 @@ function refTarget(
523
534
  if (!found) return { reason: "unresolved", pointer, name };
524
535
  if ("ambiguous" in found) return { reason: "ambiguous", pointer, name };
525
536
  const manifest = found.manifest;
537
+ if (isInjectedDeclaration(manifest as ResourceManifest)) {
538
+ return { reason: "injected", pointer, name };
539
+ }
526
540
  if (typeof manifest.kind !== "string") return { reason: "unresolved", pointer, name };
527
541
  const definition = scope.resolveDefinition(manifest.kind);
528
542
  if (!definition) return { reason: "no-projection", pointer, kind: manifest.kind };
@@ -550,6 +564,11 @@ export function describeProjectionFailure(failure: ProjectionFailure): string {
550
564
  : `'${failure.pointer}' does not hold a reference, so there is no declaration to project.`;
551
565
  case "unresolved":
552
566
  return `'${failure.pointer}' references '${failure.name}', which resolves to no resource.`;
567
+ case "injected":
568
+ return (
569
+ `'${failure.pointer}' references '${failure.name}', a resource input this module does ` +
570
+ `not declare — its entries belong to whoever supplies it.`
571
+ );
553
572
  case "ambiguous":
554
573
  return (
555
574
  `'${failure.pointer}' references '${failure.name}', which matches more than one resource ` +
@@ -28,8 +28,13 @@ export const DEPENDENCY_GRAPH_SKIP_KINDS: ReadonlySet<string> = new Set([
28
28
  /** Skipped by `!ref` sentinel resolution: kinds whose bodies are
29
29
  * blueprints or import-time metadata, not resource instances with
30
30
  * user-referenced ref slots. Mirrors `REF_VALIDATION_SKIP_KINDS` but
31
- * also drops Telo.Import (its `source` isn't a ref slot, and walking
32
- * the field map on it is pointless since there's no registered kind). */
31
+ * also lists Telo.Import, because walking its field map is pointless
32
+ * there is no registered kind behind it.
33
+ *
34
+ * `resolveRefSentinels` reaches Telo.Import's `resources:` block AHEAD of this
35
+ * set, deliberately: those are the references an importer supplies for a
36
+ * library's declared inputs, and they resolve exactly like any other. Nothing
37
+ * else on the document is a reference slot, so only that subtree is walked. */
33
38
  export const REF_RESOLUTION_SKIP_KINDS: ReadonlySet<string> = new Set([
34
39
  "Telo.Definition",
35
40
  "Telo.Abstract",
@@ -6,4 +6,4 @@
6
6
  // written against, and every kernel in every language reports the same scale.
7
7
 
8
8
  /** The surface generation this analyzer implements. */
9
- export const TELO_SURFACE_VERSION = "0.82.1";
9
+ export const TELO_SURFACE_VERSION = "0.83.0";
@@ -0,0 +1,104 @@
1
+ import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
+ import type { AliasResolver } from "./alias-resolver.js";
3
+ import type { DefinitionRegistry } from "./definition-registry.js";
4
+ import type { ModuleScopes } from "./alias-resolver.js";
5
+
6
+ /**
7
+ * A `Telo.Definition`'s `resources:` entries — the bodies a kind writes for
8
+ * ANOTHER kind — read as what they are: declarations of that other kind.
9
+ *
10
+ * The consequence is the whole point. CEL inside such a body used to be typed in
11
+ * the ENCLOSING definition's scope, against one fixed permissive context
12
+ * (`self`, plus open `request` / `result` / `steps` / `error`). So `inputs` and
13
+ * `item` were undefined wherever the nested kind declares them, while `error`
14
+ * was offered everywhere regardless of whether a `catch:` was in scope — a body
15
+ * of more than one dispatch was unwritable, which is why no standard-library
16
+ * template has one.
17
+ *
18
+ * Resolving through the nested kind's OWN annotations — its `x-telo-context`
19
+ * regions, its step body, its error branches — makes a nested declaration answer
20
+ * exactly as the same declaration written at the top level does, with `self`
21
+ * merged in from the enclosing definition's `schema:`.
22
+ *
23
+ * Browser-safe.
24
+ */
25
+ export interface TemplateBody {
26
+ /** Concrete path prefix of this entry (`resources[0]`), as CEL sites are
27
+ * addressed. */
28
+ prefix: string;
29
+ /** JSONPath prefix for context scopes (`$.resources[0]`). */
30
+ scopePrefix: string;
31
+ manifest: ResourceManifest;
32
+ definition: ResourceDefinition | undefined;
33
+ }
34
+
35
+ /** The template bodies a manifest declares — empty for anything that is not a
36
+ * `Telo.Definition` with a `resources:` array. */
37
+ export function templateBodies(
38
+ m: ResourceManifest,
39
+ registry: DefinitionRegistry,
40
+ aliases: AliasResolver | undefined,
41
+ scopes: ModuleScopes | undefined,
42
+ ): TemplateBody[] {
43
+ if (m.kind !== "Telo.Definition") return [];
44
+ const bodies = (m as Record<string, unknown>).resources;
45
+ if (!Array.isArray(bodies)) return [];
46
+
47
+ // A nested kind is written through the alias scope of the module that DECLARED
48
+ // the definition, never the consumer's — the same rule every other kind
49
+ // resolution in a forwarded manifest follows.
50
+ const ownModule = (m.metadata as { module?: string } | undefined)?.module;
51
+ const scope =
52
+ (ownModule && scopes && !scopes.rootModules.has(ownModule)
53
+ ? scopes.aliasesByModule.get(ownModule)
54
+ : undefined) ?? aliases;
55
+
56
+ const out: TemplateBody[] = [];
57
+ for (let i = 0; i < bodies.length; i++) {
58
+ const body = bodies[i];
59
+ if (!body || typeof body !== "object" || Array.isArray(body)) continue;
60
+ const kind = (body as { kind?: unknown }).kind;
61
+ if (typeof kind !== "string") continue;
62
+ const canonical = scope?.resolveKind(kind);
63
+ const definition = registry.resolve(kind) ?? (canonical ? registry.resolve(canonical) : undefined);
64
+ out.push({
65
+ prefix: `resources[${i}]`,
66
+ scopePrefix: `$.resources[${i}]`,
67
+ manifest: body as ResourceManifest,
68
+ definition,
69
+ });
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** True when a CEL path lies at or inside a body's own subtree. */
75
+ export function pathInBody(path: string, prefix: string): boolean {
76
+ return (
77
+ path === prefix || path.startsWith(`${prefix}.`) || path.startsWith(`${prefix}[`)
78
+ );
79
+ }
80
+
81
+ /** The body a CEL path belongs to, or undefined for a path in the definition's
82
+ * own fields. Generic over what the caller keyed to each body — the resolver
83
+ * carries a step context and an error map, the visitor carries the schema. */
84
+ export function bodyForPath<T extends { prefix: string }>(
85
+ bodies: readonly T[],
86
+ path: string,
87
+ ): T | undefined {
88
+ return bodies.find((b) => pathInBody(path, b.prefix));
89
+ }
90
+
91
+ /** `self` is in scope throughout a template body — it is how the body reaches
92
+ * the configuration its enclosing kind was given — so it is merged into every
93
+ * context the nested kind declares, which knows nothing about it. Merged UNDER
94
+ * the nested kind's own properties: a name the nested kind declares wins, since
95
+ * that is what its controller will bind. */
96
+ export function withTemplateSelf(contextSchema: Record<string, any>): Record<string, any> {
97
+ return {
98
+ ...contextSchema,
99
+ properties: {
100
+ self: { "x-telo-context-from-root": "schema" },
101
+ ...(contextSchema.properties ?? {}),
102
+ },
103
+ };
104
+ }
@@ -14,6 +14,30 @@ import { withRefSlotsAsReadings } from "./ref-slot-reading.js";
14
14
  export { extractCelRegionScopes, pathMatchesScope } from "./eval-paths.js";
15
15
  import { pathMatchesScope } from "./eval-paths.js";
16
16
 
17
+ /** True when a node's value is decided at load or dispatch rather than written
18
+ * — a tagged sentinel (`!cel`, `!sql`, an embed) or an already-compiled value.
19
+ * A schema position holding one declares a shape nothing static can read. */
20
+ function isDynamicNode(value: unknown): boolean {
21
+ if (!value || typeof value !== "object") return false;
22
+ const node = value as Record<string, unknown>;
23
+ return node.__tagged === true || node.__compiled !== undefined;
24
+ }
25
+
26
+ /** Replace every dynamically-valued property with an OPEN schema, so the name is
27
+ * in scope with its members unconstrained. */
28
+ function openDynamicProperties(
29
+ props: Record<string, any> | undefined,
30
+ ): Record<string, any> | undefined {
31
+ if (!props || typeof props !== "object") return props;
32
+ let out: Record<string, any> | undefined;
33
+ for (const [key, value] of Object.entries(props)) {
34
+ if (!isDynamicNode(value)) continue;
35
+ out ??= { ...props };
36
+ out[key] = {};
37
+ }
38
+ return out ?? props;
39
+ }
40
+
17
41
  export interface ContextResolveOpts {
18
42
  /** When provided, used to resolve `x-telo-context-from-root` annotations against the
19
43
  * root manifest. When omitted, defaults to `manifestItem`. */
@@ -357,8 +381,19 @@ export function resolveContextAnnotations(
357
381
  // to be resolved first: the standard library writes it as the inline
358
382
  // `{ kind: Type.JsonSchema, schema: … }` wrapper, so merging it verbatim
359
383
  // would type the variable as `{ kind, schema }` instead of its properties.
384
+ // The navigated node — or one of the properties in it — may itself be an
385
+ // expression: a route whose `request.schema.body` is `!cel "self.model.schema"`
386
+ // declares a body whose SHAPE is only known once the template is
387
+ // instantiated. Such a name types as OPEN rather than as nothing, and a
388
+ // wholly dynamic map leaves the whole node open. Resolving it to nothing
389
+ // reports `request.body` as undefined, which blames the reader for the
390
+ // writer's dynamism — the same posture a rule takes when a value it reads
391
+ // is dynamic: skip the judgement, never invert it.
392
+ if (isDynamicNode(navigated)) {
393
+ return { ...schema, properties: { ...(schema.properties ?? {}) }, additionalProperties: true };
394
+ }
360
395
  const asType = resolveTypeFieldToSchema(navigated, allManifests ?? []);
361
- const resolved = asType?.properties ?? navigated;
396
+ const resolved = openDynamicProperties(asType?.properties ?? navigated);
362
397
  const required = Array.isArray(asType?.required) ? asType.required : undefined;
363
398
  return {
364
399
  ...schema,
@@ -520,12 +555,37 @@ export function getManifestItem(
520
555
  manifest: Record<string, any>,
521
556
  ): Record<string, any> {
522
557
  const stripped = scope.startsWith("$.") ? scope.slice(2) : scope;
523
- const wildcardIdx = stripped.indexOf("[*]");
524
- if (wildcardIdx === -1) return manifest;
525
- const arrayProp = stripped.slice(0, wildcardIdx); // e.g. "routes"
526
- const m = exprPath.match(new RegExp(`^${arrayProp}\\[(\\d+)\\]`));
527
- if (!m) return manifest;
528
- return (manifest as any)[arrayProp]?.[Number(m[1])] ?? manifest;
558
+ const parts = stripped.split("[*]");
559
+ if (parts.length === 1) return manifest;
560
+ // Resolve each `[*]` against the concrete index the expression path carries at
561
+ // the same position, accumulating up to the LAST wildcard: that node is the
562
+ // per-scope item (`routes[2]`), which is what `x-telo-context-from` navigates
563
+ // from. Built by walking the pattern rather than by a regex over the scope —
564
+ // a scope is a path, not a pattern, and its own `[4]` segments are regex
565
+ // character classes, which is what silently made every nested scope resolve
566
+ // to the whole document instead of the item.
567
+ let remaining = exprPath;
568
+ let concrete = "";
569
+ for (let i = 0; i < parts.length - 1; i++) {
570
+ const part = parts[i]!;
571
+ if (!remaining.startsWith(part)) return manifest;
572
+ remaining = remaining.slice(part.length);
573
+ const index = remaining.match(/^\[(\d+)\]/);
574
+ if (!index) return manifest;
575
+ remaining = remaining.slice(index[0].length);
576
+ concrete += `${part}${index[0]}`;
577
+ }
578
+ return (navigateConcretePath(manifest, concrete) as Record<string, any> | undefined) ?? manifest;
579
+ }
580
+
581
+ /** Walk a concrete dotted path with `[N]` indices (`resources[4].routes[2]`). */
582
+ function navigateConcretePath(root: unknown, path: string): unknown {
583
+ let cur: unknown = root;
584
+ for (const segment of path.match(/[^.[\]]+/g) ?? []) {
585
+ if (cur === null || typeof cur !== "object") return undefined;
586
+ cur = (cur as Record<string, unknown>)[segment];
587
+ }
588
+ return cur;
529
589
  }
530
590
 
531
591
  function navigatePath(obj: unknown, segments: string[]): unknown {
@@ -9,6 +9,7 @@ import {
9
9
  type NameLevel,
10
10
  type NameViolation,
11
11
  } from "./identifier-name.js";
12
+ import { isInjectedDeclaration } from "./resource-input.js";
12
13
  import { type AnalysisDiagnostic } from "./types.js";
13
14
 
14
15
  const SOURCE = "telo-analyzer";
@@ -56,6 +57,11 @@ export function validateIdentifierNames(
56
57
  // would blame them for a spelling this pass's own pipeline chose.
57
58
  if (metadata?.xTeloOrigin) continue;
58
59
 
60
+ // A kind-only stand-in for a `resources:` entry carries the name the author
61
+ // wrote in that block, and the block's own keys are checked below — on the
62
+ // module doc, where the name is written and a squiggle can land.
63
+ if (isInjectedDeclaration(manifest)) continue;
64
+
59
65
  const ownModule = metadata?.module as string | undefined;
60
66
  if (ownModule && !rootModules.has(ownModule)) continue;
61
67
 
@@ -74,7 +80,10 @@ export function validateIdentifierNames(
74
80
  // keys are the imported library's declarations, so a violation there is
75
81
  // the library author's, reported when their module is analyzed as a root.
76
82
  if (manifest.kind === "Telo.Application" || manifest.kind === "Telo.Library") {
77
- for (const field of ["variables", "secrets", "ports"] as const) {
83
+ // `resources:` joins them: a library's resource inputs are named in the
84
+ // same identifier space (`!ref connection`, `resources.connection`) and
85
+ // break the same way.
86
+ for (const field of ["variables", "secrets", "ports", "resources"] as const) {
78
87
  const block = (manifest as Record<string, unknown>)[field];
79
88
  if (!block || typeof block !== "object" || Array.isArray(block)) continue;
80
89
  for (const key of Object.keys(block as Record<string, unknown>)) {
@@ -149,8 +158,14 @@ function surfaceFor(kind: string): string {
149
158
  }
150
159
  }
151
160
 
152
- function singular(field: "variables" | "secrets" | "ports"): string {
153
- return field === "variables" ? "variable" : field === "secrets" ? "secret" : "port";
161
+ function singular(field: "variables" | "secrets" | "ports" | "resources"): string {
162
+ return field === "variables"
163
+ ? "variable"
164
+ : field === "secrets"
165
+ ? "secret"
166
+ : field === "ports"
167
+ ? "port"
168
+ : "resource input";
154
169
  }
155
170
 
156
171
  function push(