@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.
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +37 -2
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +67 -16
- package/dist/cel-scope.d.ts +8 -0
- package/dist/cel-scope.d.ts.map +1 -1
- package/dist/cel-scope.js +66 -8
- package/dist/definition-registry.d.ts +17 -0
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +35 -0
- package/dist/dependency-graph.d.ts.map +1 -1
- package/dist/dependency-graph.js +65 -0
- package/dist/flatten-for-analyzer.d.ts +36 -0
- package/dist/flatten-for-analyzer.d.ts.map +1 -1
- package/dist/flatten-for-analyzer.js +103 -4
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/inline-imports.d.ts.map +1 -1
- package/dist/inline-imports.js +1 -0
- package/dist/manifest-visitor.d.ts +4 -0
- package/dist/manifest-visitor.d.ts.map +1 -1
- package/dist/manifest-visitor.js +28 -0
- package/dist/precompile.d.ts.map +1 -1
- package/dist/precompile.js +8 -0
- package/dist/resolve-ref-sentinels.d.ts.map +1 -1
- package/dist/resolve-ref-sentinels.js +14 -1
- package/dist/resource-input.d.ts +75 -0
- package/dist/resource-input.d.ts.map +1 -0
- package/dist/resource-input.js +90 -0
- package/dist/schema-projection.d.ts +13 -0
- package/dist/schema-projection.d.ts.map +1 -1
- package/dist/schema-projection.js +7 -0
- package/dist/system-kinds.d.ts +7 -2
- package/dist/system-kinds.d.ts.map +1 -1
- package/dist/system-kinds.js +7 -2
- package/dist/telo-version.d.ts +1 -1
- package/dist/telo-version.js +1 -1
- package/dist/template-body.d.ts +50 -0
- package/dist/template-body.d.ts.map +1 -0
- package/dist/template-body.js +58 -0
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +68 -8
- package/dist/validate-identifier-names.d.ts.map +1 -1
- package/dist/validate-identifier-names.js +17 -2
- package/dist/validate-references.d.ts +17 -0
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +68 -16
- package/dist/validate-resource-inputs.d.ts +35 -0
- package/dist/validate-resource-inputs.d.ts.map +1 -0
- package/dist/validate-resource-inputs.js +319 -0
- package/dist/validate-template-dispatch.d.ts +27 -0
- package/dist/validate-template-dispatch.d.ts.map +1 -0
- package/dist/validate-template-dispatch.js +95 -0
- package/package.json +3 -3
- package/src/analyzer.ts +45 -2
- package/src/builtins.ts +69 -16
- package/src/cel-scope.ts +90 -14
- package/src/definition-registry.ts +36 -0
- package/src/dependency-graph.ts +66 -0
- package/src/flatten-for-analyzer.ts +116 -3
- package/src/index.ts +12 -0
- package/src/inline-imports.ts +1 -0
- package/src/manifest-visitor.ts +33 -0
- package/src/precompile.ts +8 -0
- package/src/resolve-ref-sentinels.ts +12 -1
- package/src/resource-input.ts +132 -0
- package/src/schema-projection.ts +19 -0
- package/src/system-kinds.ts +7 -2
- package/src/telo-version.ts +1 -1
- package/src/template-body.ts +104 -0
- package/src/validate-cel-context.ts +67 -7
- package/src/validate-identifier-names.ts +18 -3
- package/src/validate-references.ts +70 -14
- package/src/validate-resource-inputs.ts +367 -0
- package/src/validate-template-dispatch.ts +99 -0
package/src/manifest-visitor.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/schema-projection.ts
CHANGED
|
@@ -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 ` +
|
package/src/system-kinds.ts
CHANGED
|
@@ -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
|
|
32
|
-
*
|
|
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",
|
package/src/telo-version.ts
CHANGED
|
@@ -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
|
|
524
|
-
if (
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
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"
|
|
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(
|