@telorun/analyzer 0.66.0 → 0.68.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 (99) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/analyzer.js +60 -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/extends-resolution.d.ts +20 -0
  14. package/dist/extends-resolution.d.ts.map +1 -1
  15. package/dist/extends-resolution.js +29 -0
  16. package/dist/flatten-for-analyzer.d.ts +36 -0
  17. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  18. package/dist/flatten-for-analyzer.js +103 -4
  19. package/dist/index.d.ts +8 -4
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +6 -4
  22. package/dist/inline-imports.d.ts.map +1 -1
  23. package/dist/inline-imports.js +1 -0
  24. package/dist/invocation-contract.d.ts +20 -0
  25. package/dist/invocation-contract.d.ts.map +1 -1
  26. package/dist/invocation-contract.js +74 -0
  27. package/dist/manifest-visitor.d.ts +4 -0
  28. package/dist/manifest-visitor.d.ts.map +1 -1
  29. package/dist/manifest-visitor.js +28 -0
  30. package/dist/precompile.d.ts.map +1 -1
  31. package/dist/precompile.js +8 -0
  32. package/dist/resolve-ref-sentinels.d.ts.map +1 -1
  33. package/dist/resolve-ref-sentinels.js +14 -1
  34. package/dist/resource-input.d.ts +75 -0
  35. package/dist/resource-input.d.ts.map +1 -0
  36. package/dist/resource-input.js +90 -0
  37. package/dist/schema-keywords.d.ts +16 -1
  38. package/dist/schema-keywords.d.ts.map +1 -1
  39. package/dist/schema-keywords.js +20 -0
  40. package/dist/schema-projection.d.ts +13 -0
  41. package/dist/schema-projection.d.ts.map +1 -1
  42. package/dist/schema-projection.js +7 -0
  43. package/dist/system-kinds.d.ts +7 -2
  44. package/dist/system-kinds.d.ts.map +1 -1
  45. package/dist/system-kinds.js +7 -2
  46. package/dist/telo-version.d.ts +1 -1
  47. package/dist/telo-version.js +1 -1
  48. package/dist/template-body.d.ts +50 -0
  49. package/dist/template-body.d.ts.map +1 -0
  50. package/dist/template-body.js +58 -0
  51. package/dist/validate-cel-context.d.ts.map +1 -1
  52. package/dist/validate-cel-context.js +68 -8
  53. package/dist/validate-extends.d.ts.map +1 -1
  54. package/dist/validate-extends.js +42 -1
  55. package/dist/validate-identifier-names.d.ts.map +1 -1
  56. package/dist/validate-identifier-names.js +17 -2
  57. package/dist/validate-references.d.ts +17 -0
  58. package/dist/validate-references.d.ts.map +1 -1
  59. package/dist/validate-references.js +68 -16
  60. package/dist/validate-resource-inputs.d.ts +35 -0
  61. package/dist/validate-resource-inputs.d.ts.map +1 -0
  62. package/dist/validate-resource-inputs.js +319 -0
  63. package/dist/validate-sensitive-slots.d.ts +28 -0
  64. package/dist/validate-sensitive-slots.d.ts.map +1 -0
  65. package/dist/validate-sensitive-slots.js +75 -0
  66. package/dist/validate-template-dispatch.d.ts +27 -0
  67. package/dist/validate-template-dispatch.d.ts.map +1 -0
  68. package/dist/validate-template-dispatch.js +95 -0
  69. package/dist/value-type-keyword.d.ts +1 -1
  70. package/dist/value-type-keyword.d.ts.map +1 -1
  71. package/dist/value-type-keyword.js +1 -0
  72. package/package.json +3 -3
  73. package/src/analyzer.ts +71 -2
  74. package/src/builtins.ts +69 -16
  75. package/src/cel-scope.ts +90 -14
  76. package/src/definition-registry.ts +36 -0
  77. package/src/dependency-graph.ts +66 -0
  78. package/src/extends-resolution.ts +33 -0
  79. package/src/flatten-for-analyzer.ts +116 -3
  80. package/src/index.ts +16 -0
  81. package/src/inline-imports.ts +1 -0
  82. package/src/invocation-contract.ts +71 -0
  83. package/src/manifest-visitor.ts +33 -0
  84. package/src/precompile.ts +8 -0
  85. package/src/resolve-ref-sentinels.ts +12 -1
  86. package/src/resource-input.ts +132 -0
  87. package/src/schema-keywords.ts +33 -1
  88. package/src/schema-projection.ts +19 -0
  89. package/src/system-kinds.ts +7 -2
  90. package/src/telo-version.ts +1 -1
  91. package/src/template-body.ts +104 -0
  92. package/src/validate-cel-context.ts +67 -7
  93. package/src/validate-extends.ts +51 -1
  94. package/src/validate-identifier-names.ts +18 -3
  95. package/src/validate-references.ts +70 -14
  96. package/src/validate-resource-inputs.ts +367 -0
  97. package/src/validate-sensitive-slots.ts +109 -0
  98. package/src/validate-template-dispatch.ts +99 -0
  99. package/src/value-type-keyword.ts +1 -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
+ }
@@ -27,6 +27,9 @@
27
27
  */
28
28
 
29
29
  import { X_TELO_TYPE } from "@telorun/sdk";
30
+
31
+ /** The one annotation read from a DATA schema rather than a kind schema. */
32
+ const X_TELO_SENSITIVE = "x-telo-sensitive" as const;
30
33
  import { ANNOTATION_KEYWORDS } from "./value-type-keyword.js";
31
34
 
32
35
  /** A keyword entry: the JSON Schema its VALUE must satisfy, carrying the title
@@ -227,7 +230,13 @@ export function jsonSchemaKeywords(self: string): SchemaKeywords {
227
230
  * a schema describing an annotation from an annotation, which nothing does yet.
228
231
  */
229
232
  export const TELO_SCHEMA_ANNOTATIONS: Record<
230
- (typeof ANNOTATION_KEYWORDS)[number] | typeof X_TELO_TYPE,
233
+ // Exhaustive over the kind vocabulary, so adding a keyword without an entry is
234
+ // a compile error. `x-telo-sensitive` is excluded because it belongs to the
235
+ // DATA vocabulary below — it is read from a contract, not from a kind's own
236
+ // `schema:` — and offering it here would put it on the one schema where
237
+ // nothing reads it.
238
+ | Exclude<(typeof ANNOTATION_KEYWORDS)[number], typeof X_TELO_SENSITIVE>
239
+ | typeof X_TELO_TYPE,
231
240
  Record<string, unknown>
232
241
  > = {
233
242
  "x-telo-eval": {
@@ -381,3 +390,26 @@ export const TELO_SCHEMA_ANNOTATIONS: Record<
381
390
  type: "string",
382
391
  },
383
392
  };
393
+
394
+ /**
395
+ * Annotations that belong on a DATA schema — an `inputType` / `outputType`
396
+ * contract — rather than on a kind's own `schema:`.
397
+ *
398
+ * Split out because {@link TELO_SCHEMA_ANNOTATIONS} is offered only where the
399
+ * fragment is `KindSchema`, which is a kind's CONFIGURATION. `x-telo-sensitive`
400
+ * is read from the opposite place: the kernel resolves it off a bound contract,
401
+ * which stamps `JsonSchema7`. Offering it from the kind vocabulary alone put it
402
+ * on the one schema where nothing reads it and withheld it from the two where it
403
+ * is the whole mechanism.
404
+ */
405
+ export const TELO_DATA_SCHEMA_ANNOTATIONS: Record<
406
+ typeof X_TELO_SENSITIVE,
407
+ Record<string, unknown>
408
+ > = {
409
+ [X_TELO_SENSITIVE]: {
410
+ title: "Sensitive",
411
+ description:
412
+ "This value is auth material or equivalent: carry it as `[redacted]` in trace payloads and on the debug wire rather than verbatim. Read only from a resource's declared `inputType` / `outputType`.",
413
+ type: "boolean",
414
+ },
415
+ };
@@ -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.84.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 {
@@ -1,7 +1,13 @@
1
1
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import type { AliasResolver } from "./alias-resolver.js";
3
3
  import type { DefinitionRegistry } from "./definition-registry.js";
4
- import { inheritedCapability, type DefResolver } from "./extends-resolution.js";
4
+ import {
5
+ controllerBearingAncestor,
6
+ effectiveAuthorSchema,
7
+ inheritedCapability,
8
+ isInheritedDelegation,
9
+ type DefResolver,
10
+ } from "./extends-resolution.js";
5
11
  import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
6
12
 
7
13
  const SOURCE = "telo-analyzer";
@@ -25,6 +31,9 @@ const EXTENDS_ALIAS_RE = /^[A-Z][A-Za-z0-9_]*\.[A-Z][A-Za-z0-9_]*$/;
25
31
  * - EXTENDS_UNKNOWN_TARGET: alias resolves to a module, but that module has no
26
32
  * registered definition with the target name.
27
33
  * - EXTENDS_NON_ABSTRACT: target resolves to a Telo.Definition, not a Telo.Abstract.
34
+ * - EXTENDS_CLOSED_PARENT_ADDS_FIELD: a child with no `base:` declares a property its
35
+ * controller-bearing ancestor's closed schema rejects — the runtime counterpart is a
36
+ * create-time failure phrased against the ancestor kind.
28
37
  * - CAPABILITY_SHADOWS_EXTENDS (warning): `capability` names a user-declared abstract
29
38
  * (metadata.module !== "Telo"). Builtin lifecycle capabilities (Telo.Invocable, etc.)
30
39
  * never trigger this — they're lifecycle roles by design.
@@ -149,6 +158,47 @@ export function validateExtends(
149
158
  data: { resource, filePath, path: "capability" },
150
159
  });
151
160
  }
161
+
162
+ // A merge-form child (no `base:`) forwards its WHOLE config to the
163
+ // inherited controller as the parent's config, so a field the
164
+ // parent's schema closes out is rejected at the parent's own
165
+ // create-time validation — phrased against the parent kind, at the
166
+ // instance's line, about a resource the author never wrote.
167
+ // Reported here instead, at the property that has to change.
168
+ if (
169
+ (m as { base?: unknown }).base == null &&
170
+ isInheritedDelegation(m as ResourceDefinition, resolveDef)
171
+ ) {
172
+ const ancestor = controllerBearingAncestor(m as ResourceDefinition, resolveDef);
173
+ const ancestorSchema = ancestor
174
+ ? effectiveAuthorSchema(ancestor, resolveDef)
175
+ : undefined;
176
+ if (ancestorSchema?.additionalProperties === false) {
177
+ const inherited = new Set(Object.keys(ancestorSchema.properties ?? {}));
178
+ const ownProps = ((m as { schema?: { properties?: Record<string, unknown> } })
179
+ .schema?.properties ?? {}) as Record<string, unknown>;
180
+ const ancestorKind = `${ancestor?.metadata.module}.${ancestor?.metadata.name}`;
181
+ for (const prop of Object.keys(ownProps)) {
182
+ if (inherited.has(prop)) continue;
183
+ diagnostics.push({
184
+ severity: DiagnosticSeverity.Error,
185
+ code: "EXTENDS_CLOSED_PARENT_ADDS_FIELD",
186
+ source: SOURCE,
187
+ message:
188
+ `${label}: declares '${prop}', but '${ancestorKind}' closes its schema ` +
189
+ `(additionalProperties: false) and a child without 'base:' forwards its whole ` +
190
+ `config as that kind's config — so '${prop}' is rejected when the resource is ` +
191
+ `created. Add a 'base:' mapping, which makes this kind's fields construction ` +
192
+ `inputs and builds the parent's config explicitly.`,
193
+ data: {
194
+ resource,
195
+ filePath,
196
+ path: `schema.properties.${prop}`,
197
+ },
198
+ });
199
+ }
200
+ }
201
+ }
152
202
  }
153
203
  }
154
204
  }