@telorun/analyzer 0.10.1 → 0.12.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/LICENSE +2 -2
- package/README.md +3 -3
- package/dist/adapters/http-adapter.d.ts +10 -0
- package/dist/adapters/http-adapter.d.ts.map +1 -0
- package/dist/adapters/http-adapter.js +18 -0
- package/dist/adapters/node-adapter.d.ts +17 -0
- package/dist/adapters/node-adapter.d.ts.map +1 -0
- package/dist/adapters/node-adapter.js +71 -0
- package/dist/adapters/registry-adapter.d.ts +15 -0
- package/dist/adapters/registry-adapter.d.ts.map +1 -0
- package/dist/adapters/registry-adapter.js +53 -0
- package/dist/analysis-registry.d.ts +7 -0
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +38 -0
- package/dist/analyzer.d.ts +15 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +268 -7
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +172 -1
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +16 -0
- package/dist/dependency-graph.d.ts.map +1 -1
- package/dist/dependency-graph.js +27 -13
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/kernel-globals.d.ts.map +1 -1
- package/dist/kernel-globals.js +9 -11
- package/dist/manifest-loader.d.ts +23 -1
- package/dist/manifest-loader.d.ts.map +1 -1
- package/dist/manifest-loader.js +66 -3
- package/dist/normalize-inline-resources.d.ts.map +1 -1
- package/dist/normalize-inline-resources.js +26 -14
- package/dist/position-metadata.d.ts +11 -2
- package/dist/position-metadata.d.ts.map +1 -1
- package/dist/position-metadata.js +18 -3
- package/dist/precompile.d.ts.map +1 -1
- package/dist/precompile.js +9 -1
- package/dist/reference-field-map.d.ts +21 -4
- package/dist/reference-field-map.d.ts.map +1 -1
- package/dist/reference-field-map.js +93 -25
- package/dist/residual-schema.d.ts +23 -0
- package/dist/residual-schema.d.ts.map +1 -0
- package/dist/residual-schema.js +45 -0
- package/dist/resolve-ref-sentinels.d.ts +27 -0
- package/dist/resolve-ref-sentinels.d.ts.map +1 -0
- package/dist/resolve-ref-sentinels.js +114 -0
- package/dist/rewrite-synthetic-origins.d.ts +10 -0
- package/dist/rewrite-synthetic-origins.d.ts.map +1 -0
- package/dist/rewrite-synthetic-origins.js +55 -0
- package/dist/schema-compat.d.ts +7 -1
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +19 -2
- package/dist/system-kinds.d.ts +25 -0
- package/dist/system-kinds.d.ts.map +1 -0
- package/dist/system-kinds.js +34 -0
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validate-cel-context.d.ts +61 -7
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +90 -8
- package/dist/validate-provider-coherence.d.ts +23 -0
- package/dist/validate-provider-coherence.d.ts.map +1 -0
- package/dist/validate-provider-coherence.js +148 -0
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +141 -36
- package/dist/with-synthetic-positions.d.ts +28 -0
- package/dist/with-synthetic-positions.d.ts.map +1 -0
- package/dist/with-synthetic-positions.js +45 -0
- package/package.json +7 -4
- package/src/analysis-registry.ts +37 -0
- package/src/analyzer.ts +313 -9
- package/src/builtins.ts +172 -1
- package/src/definition-registry.ts +15 -0
- package/src/dependency-graph.ts +27 -14
- package/src/index.ts +2 -0
- package/src/kernel-globals.ts +9 -11
- package/src/manifest-loader.ts +69 -4
- package/src/normalize-inline-resources.ts +48 -13
- package/src/position-metadata.ts +18 -3
- package/src/precompile.ts +8 -1
- package/src/reference-field-map.ts +129 -24
- package/src/residual-schema.ts +49 -0
- package/src/resolve-ref-sentinels.ts +127 -0
- package/src/rewrite-synthetic-origins.ts +75 -0
- package/src/schema-compat.ts +19 -2
- package/src/system-kinds.ts +37 -0
- package/src/types.ts +12 -0
- package/src/validate-cel-context.ts +111 -8
- package/src/validate-provider-coherence.ts +166 -0
- package/src/validate-references.ts +138 -35
- package/src/with-synthetic-positions.ts +48 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the residual JSON Schema for a `variables` / `secrets` entry.
|
|
3
|
+
*
|
|
4
|
+
* For Telo.Application env-binding entries (those with an `env:` key), strips
|
|
5
|
+
* the kernel-specific wrapper keys `env` and `default` — `default` here is
|
|
6
|
+
* the *fallback host value* the kernel coerces when the env var is unset, not
|
|
7
|
+
* a JSON Schema annotation, so it must not leak into the validator.
|
|
8
|
+
*
|
|
9
|
+
* For Telo.Library entries (no `env:`), passes the entry through unchanged.
|
|
10
|
+
* Library `default:` is a standard JSON Schema annotation and stays.
|
|
11
|
+
*
|
|
12
|
+
* Single source of truth for "residual schema" referenced by both the
|
|
13
|
+
* analyzer's CEL globals normalization and the kernel's runtime env-var
|
|
14
|
+
* resolver — keeping them aligned prevents the two surfaces from drifting.
|
|
15
|
+
*/
|
|
16
|
+
export function residualEntrySchema(
|
|
17
|
+
entry: Record<string, unknown> | null | undefined,
|
|
18
|
+
): Record<string, unknown> {
|
|
19
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
20
|
+
return { type: "object", additionalProperties: true };
|
|
21
|
+
}
|
|
22
|
+
const isAppEnvBinding = "env" in entry;
|
|
23
|
+
const out: Record<string, unknown> = {};
|
|
24
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
25
|
+
if (isAppEnvBinding && (key === "env" || key === "default")) continue;
|
|
26
|
+
out[key] = value;
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Apply `residualEntrySchema` to every entry in a `variables` / `secrets` map.
|
|
33
|
+
* Returns a property-map suitable for use as the inner schema of CEL's
|
|
34
|
+
* `variables` / `secrets` namespaces.
|
|
35
|
+
*/
|
|
36
|
+
export function residualEntrySchemaMap(
|
|
37
|
+
entries: Record<string, unknown> | null | undefined,
|
|
38
|
+
): Record<string, Record<string, unknown>> {
|
|
39
|
+
const out: Record<string, Record<string, unknown>> = {};
|
|
40
|
+
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
for (const [name, value] of Object.entries(entries)) {
|
|
44
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
45
|
+
out[name] = residualEntrySchema(value as Record<string, unknown>);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { isRefSentinel } from "@telorun/templating";
|
|
3
|
+
import type { AliasResolver } from "./alias-resolver.js";
|
|
4
|
+
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
5
|
+
import { isRefEntry } from "./reference-field-map.js";
|
|
6
|
+
import { REF_RESOLUTION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Walks every `x-telo-ref` slot in every non-system resource and rewrites
|
|
10
|
+
* `!ref <name>` sentinels in-place to `{kind: <resolved-kind>, name}`.
|
|
11
|
+
*
|
|
12
|
+
* The downstream pipeline (inline normalization, dependency graph, kernel
|
|
13
|
+
* controllers) expects every ref-slot value to be either a `{kind, name}`
|
|
14
|
+
* object, an inline-definition object, or a legacy bare string — resolving
|
|
15
|
+
* sentinels here keeps that contract intact so each consumer doesn't need
|
|
16
|
+
* its own sentinel branch.
|
|
17
|
+
*
|
|
18
|
+
* The walker assigns `kind` by name lookup (resource names are unique
|
|
19
|
+
* within a manifest scope). When the name doesn't resolve in the local
|
|
20
|
+
* `byName` map, the sentinel is left in place so `validateReferences`
|
|
21
|
+
* can emit the `UNRESOLVED_REFERENCE` diagnostic with full context.
|
|
22
|
+
*
|
|
23
|
+
* Mutation strategy: the field-path walker descends the resource tree
|
|
24
|
+
* directly and replaces the sentinel on its parent container. Re-parsing
|
|
25
|
+
* a string-encoded concrete path (the earlier shape) coupled the writer
|
|
26
|
+
* to the path-encoding rules of `resolveFieldEntries` — any new path
|
|
27
|
+
* marker would silently break this writer. Descending directly avoids
|
|
28
|
+
* that coupling.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveRefSentinels(
|
|
31
|
+
resources: ResourceManifest[],
|
|
32
|
+
registry: DefinitionRegistry,
|
|
33
|
+
aliases?: AliasResolver,
|
|
34
|
+
aliasesByModule?: Map<string, AliasResolver>,
|
|
35
|
+
): void {
|
|
36
|
+
const byName = new Map<string, ResourceManifest>();
|
|
37
|
+
for (const r of resources) {
|
|
38
|
+
if (r.metadata?.name && !SYSTEM_KINDS.has(r.kind)) {
|
|
39
|
+
byName.set(r.metadata.name as string, r);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const r of resources) {
|
|
44
|
+
if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
|
|
45
|
+
|
|
46
|
+
const fieldMap =
|
|
47
|
+
aliases && aliasesByModule
|
|
48
|
+
? registry.expandedFieldMapForResource(r, aliases, aliasesByModule)
|
|
49
|
+
: registry.getFieldMapForKind(r.kind, aliases);
|
|
50
|
+
if (!fieldMap) continue;
|
|
51
|
+
|
|
52
|
+
for (const [fieldPath, entry] of fieldMap) {
|
|
53
|
+
if (!isRefEntry(entry)) continue;
|
|
54
|
+
replaceSentinelsAtPath(r as Record<string, unknown>, fieldPath, byName);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Walks `obj` along `fieldPath` (dot notation with `[]` for arrays and
|
|
60
|
+
* `{}` for additionalProperties-typed maps) and replaces any `!ref`
|
|
61
|
+
* sentinel value at the terminal slot with `{kind, name}` looked up
|
|
62
|
+
* via `byName`. Mutates the parent container in place; no string-path
|
|
63
|
+
* round-trip. */
|
|
64
|
+
function replaceSentinelsAtPath(
|
|
65
|
+
obj: Record<string, unknown>,
|
|
66
|
+
fieldPath: string,
|
|
67
|
+
byName: Map<string, ResourceManifest>,
|
|
68
|
+
): void {
|
|
69
|
+
const parts = fieldPath.split(".");
|
|
70
|
+
descend(obj, parts, byName);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function descend(
|
|
74
|
+
obj: unknown,
|
|
75
|
+
parts: string[],
|
|
76
|
+
byName: Map<string, ResourceManifest>,
|
|
77
|
+
): void {
|
|
78
|
+
if (obj == null || typeof obj !== "object" || parts.length === 0) return;
|
|
79
|
+
const [head, ...rest] = parts;
|
|
80
|
+
|
|
81
|
+
// Map iteration: descend into every value of the current object.
|
|
82
|
+
if (head === "{}") {
|
|
83
|
+
const container = obj as Record<string, unknown>;
|
|
84
|
+
for (const key of Object.keys(container)) {
|
|
85
|
+
const child = container[key];
|
|
86
|
+
if (rest.length === 0) {
|
|
87
|
+
if (isRefSentinel(child)) {
|
|
88
|
+
const target = byName.get(child.source);
|
|
89
|
+
if (target) container[key] = { kind: target.kind as string, name: child.source };
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
descend(child, rest, byName);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const isArr = head.endsWith("[]");
|
|
99
|
+
const key = isArr ? head.slice(0, -2) : head;
|
|
100
|
+
const container = obj as Record<string, unknown>;
|
|
101
|
+
const val = container[key];
|
|
102
|
+
if (val == null) return;
|
|
103
|
+
|
|
104
|
+
if (isArr) {
|
|
105
|
+
if (!Array.isArray(val)) return;
|
|
106
|
+
for (let i = 0; i < val.length; i++) {
|
|
107
|
+
if (rest.length === 0) {
|
|
108
|
+
const elem = val[i];
|
|
109
|
+
if (isRefSentinel(elem)) {
|
|
110
|
+
const target = byName.get(elem.source);
|
|
111
|
+
if (target) val[i] = { kind: target.kind as string, name: elem.source };
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
descend(val[i], rest, byName);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
if (rest.length === 0) {
|
|
119
|
+
if (isRefSentinel(val)) {
|
|
120
|
+
const target = byName.get(val.source);
|
|
121
|
+
if (target) container[key] = { kind: target.kind as string, name: val.source };
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
descend(val, rest, byName);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import type { AnalysisDiagnostic } from "./types.js";
|
|
3
|
+
|
|
4
|
+
interface XTeloOrigin {
|
|
5
|
+
parentKind: string;
|
|
6
|
+
parentName: string;
|
|
7
|
+
pathFromParent: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function readOrigin(manifest: ResourceManifest | undefined): XTeloOrigin | undefined {
|
|
11
|
+
if (!manifest) return undefined;
|
|
12
|
+
const origin = (manifest.metadata as { xTeloOrigin?: XTeloOrigin } | undefined)?.xTeloOrigin;
|
|
13
|
+
if (
|
|
14
|
+
!origin ||
|
|
15
|
+
typeof origin.parentKind !== "string" ||
|
|
16
|
+
typeof origin.parentName !== "string" ||
|
|
17
|
+
typeof origin.pathFromParent !== "string"
|
|
18
|
+
) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
return origin;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Diagnostics emitted on synthetic manifests (resources extracted by
|
|
25
|
+
* `normalizeInlineResources`) carry the synthetic's identity in
|
|
26
|
+
* `data.resource`, which has no YAML source. Rewrite each such diagnostic
|
|
27
|
+
* back to the chain root: walk up `metadata.xTeloOrigin` until a manifest
|
|
28
|
+
* with no origin is reached, and prepend each hop's `pathFromParent` to
|
|
29
|
+
* `data.path` so position-index lookups against the root doc resolve. */
|
|
30
|
+
export function rewriteSyntheticOrigins(
|
|
31
|
+
diagnostics: AnalysisDiagnostic[],
|
|
32
|
+
manifests: ResourceManifest[],
|
|
33
|
+
): AnalysisDiagnostic[] {
|
|
34
|
+
const byName = new Map<string, ResourceManifest>();
|
|
35
|
+
for (const m of manifests) {
|
|
36
|
+
const name = m.metadata?.name;
|
|
37
|
+
if (typeof name === "string") byName.set(name, m);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return diagnostics.map((d) => {
|
|
41
|
+
const data = d.data as
|
|
42
|
+
| { resource?: { kind?: string; name?: string }; path?: string; filePath?: string }
|
|
43
|
+
| undefined;
|
|
44
|
+
if (!data?.resource?.name) return d;
|
|
45
|
+
|
|
46
|
+
let current = byName.get(data.resource.name);
|
|
47
|
+
let origin = readOrigin(current);
|
|
48
|
+
if (!origin) return d;
|
|
49
|
+
|
|
50
|
+
let accumPath = typeof data.path === "string" ? data.path : "";
|
|
51
|
+
let rootKind: string = origin.parentKind;
|
|
52
|
+
let rootName: string = origin.parentName;
|
|
53
|
+
|
|
54
|
+
while (origin) {
|
|
55
|
+
accumPath = accumPath ? `${origin.pathFromParent}.${accumPath}` : origin.pathFromParent;
|
|
56
|
+
rootKind = origin.parentKind;
|
|
57
|
+
rootName = origin.parentName;
|
|
58
|
+
current = byName.get(origin.parentName);
|
|
59
|
+
origin = readOrigin(current);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const rootFilePath =
|
|
63
|
+
(current?.metadata as { source?: string } | undefined)?.source ?? data.filePath;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
...d,
|
|
67
|
+
data: {
|
|
68
|
+
...data,
|
|
69
|
+
resource: { kind: rootKind, name: rootName },
|
|
70
|
+
filePath: rootFilePath,
|
|
71
|
+
path: accumPath,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
}
|
package/src/schema-compat.ts
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import AjvModule from "ajv";
|
|
2
2
|
import addFormats from "ajv-formats";
|
|
3
|
-
import { isTaggedSentinel } from "@telorun/templating";
|
|
3
|
+
import { isRefSentinel, isTaggedSentinel, ManifestRootSchema } from "@telorun/templating";
|
|
4
4
|
|
|
5
5
|
const Ajv = (AjvModule as any).default ?? AjvModule;
|
|
6
6
|
|
|
7
7
|
/** Creates a configured AJV instance (allErrors, strict: false, with formats).
|
|
8
|
-
*
|
|
8
|
+
* Also registers the kernel manifest root schema under `telo://manifest` so
|
|
9
|
+
* module YAMLs can `$ref` into the shared `$defs/ResourceRef` (and any future
|
|
10
|
+
* shared fragments) from this analyzer's AJV without each module having to
|
|
11
|
+
* bundle its own copy.
|
|
12
|
+
*
|
|
13
|
+
* Called once for the module-level instance and once per
|
|
14
|
+
* DefinitionRegistry instance. */
|
|
9
15
|
export function createAjv(): InstanceType<typeof Ajv> {
|
|
10
16
|
const instance = new Ajv({ allErrors: true, strict: false });
|
|
11
17
|
(addFormats as any).default
|
|
12
18
|
? (addFormats as any).default(instance)
|
|
13
19
|
: (addFormats as any)(instance);
|
|
20
|
+
instance.addSchema(ManifestRootSchema);
|
|
14
21
|
return instance;
|
|
15
22
|
}
|
|
16
23
|
|
|
@@ -301,6 +308,16 @@ export function substituteCelFields(
|
|
|
301
308
|
if (typeof data === "string" && CEL_PURE_RE.test(data)) {
|
|
302
309
|
return celPlaceholderForSchema(resolved);
|
|
303
310
|
}
|
|
311
|
+
// `!ref <name>` sentinels are identity markers, not runtime values —
|
|
312
|
+
// schemas that opt into `$ref: "telo://manifest#/$defs/ResourceRef"`
|
|
313
|
+
// (or `anyOf` it alongside other shapes) need the actual sentinel
|
|
314
|
+
// object so AJV validates it against ResourceRefSchema. Collapsing it
|
|
315
|
+
// to a CEL placeholder would either fail the schema (when the slot
|
|
316
|
+
// expects the ResourceRef shape) or mask validation errors (when the
|
|
317
|
+
// slot expects something else entirely).
|
|
318
|
+
if (isRefSentinel(data)) {
|
|
319
|
+
return data;
|
|
320
|
+
}
|
|
304
321
|
if (isTaggedSentinel(data)) {
|
|
305
322
|
return celPlaceholderForSchema(resolved);
|
|
306
323
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Resource-kind sets used by analysis passes to decide what counts as a
|
|
2
|
+
* user-defined instance vs. a system-level blueprint. Pulled into one
|
|
3
|
+
* place so the three passes (reference validation, dependency graph,
|
|
4
|
+
* ref-sentinel resolution) don't drift; each pass exports its own
|
|
5
|
+
* scoped view with a comment explaining what's in and what's out. */
|
|
6
|
+
|
|
7
|
+
/** Skipped by reference validation: type blueprints whose own ref slots
|
|
8
|
+
* belong to a different phase (definition schema validation rather than
|
|
9
|
+
* per-resource validation). Telo.Application and Telo.Library
|
|
10
|
+
* intentionally fall through — Application has `targets` (real refs) and
|
|
11
|
+
* Library is harmless (no ref-bearing fields). Telo.Import is also
|
|
12
|
+
* intentionally not skipped — its `source` is not an x-telo-ref slot, so
|
|
13
|
+
* walking it is cheap and consistent. */
|
|
14
|
+
export const REF_VALIDATION_SKIP_KINDS: ReadonlySet<string> = new Set([
|
|
15
|
+
"Telo.Definition",
|
|
16
|
+
"Telo.Abstract",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/** Excluded from the dependency graph: kinds that are not runtime nodes.
|
|
20
|
+
* Telo.Abstract is intentionally not in this set today — abstracts have
|
|
21
|
+
* no resource manifests, so they never reach graph construction; if
|
|
22
|
+
* that ever changes, add it explicitly. */
|
|
23
|
+
export const DEPENDENCY_GRAPH_SKIP_KINDS: ReadonlySet<string> = new Set([
|
|
24
|
+
"Telo.Definition",
|
|
25
|
+
"Telo.Import",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** Skipped by `!ref` sentinel resolution: kinds whose bodies are
|
|
29
|
+
* blueprints or import-time metadata, not resource instances with
|
|
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). */
|
|
33
|
+
export const REF_RESOLUTION_SKIP_KINDS: ReadonlySet<string> = new Set([
|
|
34
|
+
"Telo.Definition",
|
|
35
|
+
"Telo.Abstract",
|
|
36
|
+
"Telo.Import",
|
|
37
|
+
]);
|
package/src/types.ts
CHANGED
|
@@ -81,6 +81,18 @@ export interface LoaderInitOptions {
|
|
|
81
81
|
|
|
82
82
|
export interface AnalysisOptions {
|
|
83
83
|
strictContexts?: boolean;
|
|
84
|
+
/** When true, `analyze()` runs the state-mutating setup (module identity /
|
|
85
|
+
* alias / definition registration plus `normalizeInlineResources`) but
|
|
86
|
+
* skips every diagnostic-producing pass — per-resource validation, the
|
|
87
|
+
* Library `env:` check, `validateExtends`, `validateProviderCoherence`,
|
|
88
|
+
* and `validateThrowsCoverage`. Used by the kernel when a previous load
|
|
89
|
+
* has already stamped the manifest set as valid (by content hash), so
|
|
90
|
+
* the registry still gets populated without paying the validation walk
|
|
91
|
+
* on every cold start. The caller takes responsibility for the
|
|
92
|
+
* correctness guarantee — pass this only when something durable
|
|
93
|
+
* (on-disk stamp) attests that the manifests passed a real analyze
|
|
94
|
+
* pass at the same analyzer / kernel version. */
|
|
95
|
+
skipValidation?: boolean;
|
|
84
96
|
}
|
|
85
97
|
|
|
86
98
|
/** Pre-seeded state for incremental analysis. Passed to StaticAnalyzer.analyze() so it does
|
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
export { extractAccessChains, validateChainAgainstSchema } from "@telorun/templating";
|
|
2
2
|
|
|
3
|
+
export interface ContextResolveOpts {
|
|
4
|
+
/** When provided, used to resolve `x-telo-context-from-root` annotations against the
|
|
5
|
+
* root manifest. When omitted, defaults to `manifestItem`. */
|
|
6
|
+
manifestRoot?: Record<string, any>;
|
|
7
|
+
/** When provided alongside `aliases`, used to resolve `x-telo-context-from-ref-kind`
|
|
8
|
+
* annotations: read a kind name from a path on `manifestRoot` and return the
|
|
9
|
+
* declared definition's `<field>` schema. */
|
|
10
|
+
defs?: {
|
|
11
|
+
resolve(kind: string): Record<string, any> | undefined;
|
|
12
|
+
};
|
|
13
|
+
aliases?: {
|
|
14
|
+
resolveKind(kind: string): string | undefined;
|
|
15
|
+
};
|
|
16
|
+
allManifests?: Record<string, any>[];
|
|
17
|
+
}
|
|
18
|
+
|
|
3
19
|
/**
|
|
4
20
|
* Resolve a type field value (string name, inline type, or raw schema) to a JSON Schema.
|
|
5
21
|
* - String: look up the named type in allManifests (Type.JsonSchema resources)
|
|
@@ -70,21 +86,66 @@ export function pathMatchesScope(exprPath: string, scope: string): boolean {
|
|
|
70
86
|
}
|
|
71
87
|
|
|
72
88
|
/**
|
|
73
|
-
* Resolves `x-telo-context
|
|
74
|
-
* manifest item
|
|
75
|
-
*
|
|
89
|
+
* Resolves `x-telo-context-*` annotations in a context schema using the concrete
|
|
90
|
+
* manifest item (per-scope) and the manifest root.
|
|
91
|
+
*
|
|
92
|
+
* Annotation forms:
|
|
93
|
+
*
|
|
94
|
+
* - `x-telo-context-from`: navigates `manifestItem.<path>` and treats the resolved
|
|
95
|
+
* value as a **property map** (keys → sub-schemas) that is merged into the
|
|
96
|
+
* annotated node's properties. Used for HTTP-style scopes where the navigated
|
|
97
|
+
* value is itself a map of variable names.
|
|
98
|
+
*
|
|
99
|
+
* Example: `x-telo-context-from: "request/schema"` reads `manifestItem.request.schema`
|
|
100
|
+
* (= `{ query: {...}, body: {...}, … }`) and merges those keys as named properties
|
|
101
|
+
* of the context node.
|
|
102
|
+
*
|
|
103
|
+
* - `x-telo-context-from-root`: navigates `manifestRoot.<path>` and **replaces** the
|
|
104
|
+
* annotated node's schema with the resolved value. Used on individual property
|
|
105
|
+
* schemas (e.g. `properties.self`) where the resolved value is a single variable's
|
|
106
|
+
* full schema, not a property map.
|
|
107
|
+
*
|
|
108
|
+
* Example: `properties.self.x-telo-context-from-root: "schema"` reads
|
|
109
|
+
* `manifestRoot.schema` and uses it as the schema of the `self` CEL variable.
|
|
110
|
+
*
|
|
111
|
+
* - `x-telo-context-from-ref-kind`: reads a kind name from `manifestRoot.<refPath>`,
|
|
112
|
+
* resolves it via the definition registry, and returns that kind's `<field>` schema
|
|
113
|
+
* (e.g. `outputType`/`inputType`). Used to type `result` against the dispatch
|
|
114
|
+
* target's declared output shape.
|
|
115
|
+
*
|
|
116
|
+
* Syntax: `<refPath>#<field>` — slashes traverse the manifest tree.
|
|
117
|
+
*
|
|
118
|
+
* Example: `x-telo-context-from-ref-kind: "provide/kind#outputType"` reads
|
|
119
|
+
* `manifestRoot.provide.kind` as a kind name, looks up the kind's Telo.Definition,
|
|
120
|
+
* and returns the `outputType` schema.
|
|
121
|
+
*
|
|
122
|
+
* Accepts either a single string or an array of strings. With an array, paths
|
|
123
|
+
* are tried in order and the first one that resolves to a usable schema wins —
|
|
124
|
+
* used by `result:` to find its dispatch target under whichever entry-point
|
|
125
|
+
* field (`provide:` or `invoke:`) the definition declares.
|
|
76
126
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
127
|
+
* - `x-telo-context-ref-from`: existing form — reads `{kind, name}` object from
|
|
128
|
+
* `manifestItem.<path>`, looks up the named manifest, returns its `<subpath>` field.
|
|
129
|
+
*
|
|
130
|
+
* **Fallback chain.** When both `x-telo-context-from-root` and
|
|
131
|
+
* `x-telo-context-from-ref-kind` are present on the same node, the resolver tries
|
|
132
|
+
* `from-root` first; if that produces no usable schema, it falls back to `from-ref-kind`.
|
|
133
|
+
* This lets a definition declare typing from its own field with a sibling-kind fallback
|
|
134
|
+
* (e.g. `inputType` direct → `extends`-declared abstract's `inputType`).
|
|
80
135
|
*/
|
|
81
136
|
export function resolveContextAnnotations(
|
|
82
137
|
schema: Record<string, any>,
|
|
83
138
|
manifestItem: Record<string, any>,
|
|
84
|
-
|
|
139
|
+
opts?: ContextResolveOpts | Record<string, any>[],
|
|
85
140
|
): Record<string, any> {
|
|
86
141
|
if (!schema || typeof schema !== "object") return schema;
|
|
87
142
|
|
|
143
|
+
// Back-compat: third positional arg used to be `allManifests: Record<string, any>[]`.
|
|
144
|
+
const normalizedOpts: ContextResolveOpts = Array.isArray(opts)
|
|
145
|
+
? { allManifests: opts }
|
|
146
|
+
: (opts ?? {});
|
|
147
|
+
const { manifestRoot = manifestItem, defs, aliases, allManifests } = normalizedOpts;
|
|
148
|
+
|
|
88
149
|
const from = schema["x-telo-context-from"] as string | undefined;
|
|
89
150
|
if (from) {
|
|
90
151
|
const resolved = navigatePath(manifestItem, from.split("/")) as Record<string, any> | undefined;
|
|
@@ -96,6 +157,48 @@ export function resolveContextAnnotations(
|
|
|
96
157
|
};
|
|
97
158
|
}
|
|
98
159
|
|
|
160
|
+
const fromRoot = schema["x-telo-context-from-root"] as string | undefined;
|
|
161
|
+
const fromRefKindRaw = schema["x-telo-context-from-ref-kind"] as
|
|
162
|
+
| string
|
|
163
|
+
| string[]
|
|
164
|
+
| undefined;
|
|
165
|
+
const fromRefKinds = fromRefKindRaw == null
|
|
166
|
+
? []
|
|
167
|
+
: Array.isArray(fromRefKindRaw)
|
|
168
|
+
? fromRefKindRaw
|
|
169
|
+
: [fromRefKindRaw];
|
|
170
|
+
if (fromRoot || fromRefKinds.length > 0) {
|
|
171
|
+
if (fromRoot) {
|
|
172
|
+
const resolved = navigatePath(manifestRoot, fromRoot.split("/")) as
|
|
173
|
+
| Record<string, any>
|
|
174
|
+
| undefined;
|
|
175
|
+
if (resolved && typeof resolved === "object" && !Array.isArray(resolved)) {
|
|
176
|
+
return resolved;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (defs) {
|
|
180
|
+
for (const fromRefKind of fromRefKinds) {
|
|
181
|
+
const hashIdx = fromRefKind.indexOf("#");
|
|
182
|
+
if (hashIdx <= 0) continue;
|
|
183
|
+
const refPath = fromRefKind.slice(0, hashIdx);
|
|
184
|
+
const field = fromRefKind.slice(hashIdx + 1);
|
|
185
|
+
const kindValue = navigatePath(manifestRoot, refPath.split("/"));
|
|
186
|
+
if (typeof kindValue !== "string" || kindValue.length === 0) continue;
|
|
187
|
+
const canonical = aliases?.resolveKind(kindValue) ?? kindValue;
|
|
188
|
+
const def = defs.resolve(canonical);
|
|
189
|
+
const typeField = def
|
|
190
|
+
? (def as Record<string, unknown>)[field]
|
|
191
|
+
: undefined;
|
|
192
|
+
const resolved = resolveTypeFieldToSchema(typeField, allManifests ?? []);
|
|
193
|
+
if (resolved && typeof resolved === "object") {
|
|
194
|
+
return resolved;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Open fallback so unresolved types never produce false-positive CEL diagnostics.
|
|
199
|
+
return { type: "object", additionalProperties: true };
|
|
200
|
+
}
|
|
201
|
+
|
|
99
202
|
const refFrom = schema["x-telo-context-ref-from"] as string | undefined;
|
|
100
203
|
if (refFrom && allManifests) {
|
|
101
204
|
const slashIdx = refFrom.indexOf("/");
|
|
@@ -129,7 +232,7 @@ export function resolveContextAnnotations(
|
|
|
129
232
|
if (schema.properties) {
|
|
130
233
|
const props: Record<string, any> = {};
|
|
131
234
|
for (const [k, v] of Object.entries(schema.properties)) {
|
|
132
|
-
props[k] = resolveContextAnnotations(v as Record<string, any>, manifestItem,
|
|
235
|
+
props[k] = resolveContextAnnotations(v as Record<string, any>, manifestItem, normalizedOpts);
|
|
133
236
|
}
|
|
134
237
|
return { ...schema, properties: props };
|
|
135
238
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import type { AliasResolver } from "./alias-resolver.js";
|
|
3
|
+
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
4
|
+
import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const SOURCE = "telo-analyzer";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Validates coherence rules for `Telo.Definition` documents that use the `provide:`
|
|
10
|
+
* template target, plus the implementation-presence rule on `Telo.Provider`
|
|
11
|
+
* definitions.
|
|
12
|
+
*
|
|
13
|
+
* Diagnostics:
|
|
14
|
+
* - PROVIDE_ON_NON_PROVIDER: `provide:` declared on a definition whose
|
|
15
|
+
* `capability` is not `Telo.Provider`.
|
|
16
|
+
* - PROVIDE_DISPATCHER_CONFLICT: `provide:` co-exists with `invoke:` or `run:`
|
|
17
|
+
* on the same definition.
|
|
18
|
+
* - PROVIDE_TARGET_UNKNOWN: `provide.name` does not resolve to an entry in
|
|
19
|
+
* `resources:`.
|
|
20
|
+
* - PROVIDE_TARGET_NOT_INVOCABLE: `provide.name` resolves to a resource whose
|
|
21
|
+
* kind is registered but not a `Telo.Invocable`.
|
|
22
|
+
* - PROVIDER_MISSING_IMPLEMENTATION: definition with `capability: Telo.Provider`
|
|
23
|
+
* declares neither `controllers:` (TS-backed) nor `provide:` (template-backed).
|
|
24
|
+
*/
|
|
25
|
+
export function validateProviderCoherence(
|
|
26
|
+
manifests: ResourceManifest[],
|
|
27
|
+
registry: DefinitionRegistry,
|
|
28
|
+
aliases: AliasResolver,
|
|
29
|
+
): AnalysisDiagnostic[] {
|
|
30
|
+
const diagnostics: AnalysisDiagnostic[] = [];
|
|
31
|
+
|
|
32
|
+
const importedModules = new Set<string>();
|
|
33
|
+
for (const m of manifests) {
|
|
34
|
+
if (m.kind !== "Telo.Import") continue;
|
|
35
|
+
const resolved = (m.metadata as { resolvedModuleName?: string } | undefined)
|
|
36
|
+
?.resolvedModuleName;
|
|
37
|
+
if (resolved) importedModules.add(resolved);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const m of manifests) {
|
|
41
|
+
if (m.kind !== "Telo.Definition") continue;
|
|
42
|
+
const name = m.metadata?.name as string | undefined;
|
|
43
|
+
if (!name) continue;
|
|
44
|
+
const ownModule = (m.metadata as { module?: string } | undefined)?.module;
|
|
45
|
+
if (ownModule && importedModules.has(ownModule)) continue;
|
|
46
|
+
const filePath = (m.metadata as { source?: string } | undefined)?.source;
|
|
47
|
+
const resource = { kind: m.kind, name };
|
|
48
|
+
const label = `${m.kind}/${name}`;
|
|
49
|
+
|
|
50
|
+
const md = m as Record<string, unknown>;
|
|
51
|
+
const capability = typeof md.capability === "string" ? md.capability : undefined;
|
|
52
|
+
const provide = md.provide;
|
|
53
|
+
const invoke = md.invoke;
|
|
54
|
+
const run = md.run;
|
|
55
|
+
const controllers = md.controllers;
|
|
56
|
+
const resources = md.resources;
|
|
57
|
+
|
|
58
|
+
const hasProvide = provide !== undefined && provide !== null;
|
|
59
|
+
const hasInvoke = invoke !== undefined && invoke !== null;
|
|
60
|
+
const hasRun = run !== undefined && run !== null;
|
|
61
|
+
const hasControllers = Array.isArray(controllers) && controllers.length > 0;
|
|
62
|
+
|
|
63
|
+
if (hasProvide && capability !== "Telo.Provider") {
|
|
64
|
+
diagnostics.push({
|
|
65
|
+
severity: DiagnosticSeverity.Error,
|
|
66
|
+
code: "PROVIDE_ON_NON_PROVIDER",
|
|
67
|
+
source: SOURCE,
|
|
68
|
+
message:
|
|
69
|
+
`${label}: 'provide:' is only valid on definitions with 'capability: Telo.Provider' ` +
|
|
70
|
+
`(found '${capability ?? "<unset>"}'). Use 'invoke:' or 'run:' for other capabilities.`,
|
|
71
|
+
data: { resource, filePath, path: "provide" },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (hasProvide && (hasInvoke || hasRun)) {
|
|
76
|
+
const conflict = hasInvoke ? "invoke" : "run";
|
|
77
|
+
diagnostics.push({
|
|
78
|
+
severity: DiagnosticSeverity.Error,
|
|
79
|
+
code: "PROVIDE_DISPATCHER_CONFLICT",
|
|
80
|
+
source: SOURCE,
|
|
81
|
+
message:
|
|
82
|
+
`${label}: 'provide:' cannot co-exist with '${conflict}:'. ` +
|
|
83
|
+
`A definition declares exactly one dispatch entry-point.`,
|
|
84
|
+
data: { resource, filePath, path: "provide" },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (hasProvide && typeof provide === "object" && !Array.isArray(provide)) {
|
|
89
|
+
const provideObj = provide as { kind?: unknown; name?: unknown };
|
|
90
|
+
const providedName = typeof provideObj.name === "string" ? provideObj.name : undefined;
|
|
91
|
+
const providedKind = typeof provideObj.kind === "string" ? provideObj.kind : undefined;
|
|
92
|
+
if (providedName && Array.isArray(resources)) {
|
|
93
|
+
const match = resources.find((r) => {
|
|
94
|
+
const meta = (r as { metadata?: { name?: unknown } })?.metadata;
|
|
95
|
+
return typeof meta?.name === "string" && meta.name === providedName;
|
|
96
|
+
}) as { kind?: unknown } | undefined;
|
|
97
|
+
if (!match) {
|
|
98
|
+
diagnostics.push({
|
|
99
|
+
severity: DiagnosticSeverity.Error,
|
|
100
|
+
code: "PROVIDE_TARGET_UNKNOWN",
|
|
101
|
+
source: SOURCE,
|
|
102
|
+
message:
|
|
103
|
+
`${label}: 'provide.name: ${providedName}' does not match any entry's ` +
|
|
104
|
+
`metadata.name in 'resources:'.`,
|
|
105
|
+
data: { resource, filePath, path: "provide.name" },
|
|
106
|
+
});
|
|
107
|
+
} else if (typeof match.kind === "string") {
|
|
108
|
+
// `provide.kind` is the type contract the analyzer uses to type
|
|
109
|
+
// `result` CEL against the target's `outputType`. The runtime
|
|
110
|
+
// dispatches on `provide.name` and ignores `provide.kind`, so a
|
|
111
|
+
// mismatch silently degrades `result` typing to an open schema
|
|
112
|
+
// (and at runtime quietly invokes the actually-matched resource).
|
|
113
|
+
// Flag the divergence so result-typing never lies.
|
|
114
|
+
if (providedKind) {
|
|
115
|
+
const providedCanonical = aliases.resolveKind(providedKind) ?? providedKind;
|
|
116
|
+
const matchCanonical = aliases.resolveKind(match.kind) ?? match.kind;
|
|
117
|
+
if (providedCanonical !== matchCanonical) {
|
|
118
|
+
diagnostics.push({
|
|
119
|
+
severity: DiagnosticSeverity.Error,
|
|
120
|
+
code: "PROVIDE_KIND_MISMATCH",
|
|
121
|
+
source: SOURCE,
|
|
122
|
+
message:
|
|
123
|
+
`${label}: 'provide.kind: ${providedKind}' disagrees with the matched ` +
|
|
124
|
+
`'resources:' entry's kind '${match.kind}' (matched by metadata.name ` +
|
|
125
|
+
`'${providedName}'). The runtime dispatches by name, so 'provide.kind' ` +
|
|
126
|
+
`is decorative — but the analyzer types 'result:' against it, and a ` +
|
|
127
|
+
`mismatch silently turns off that typing.`,
|
|
128
|
+
data: { resource, filePath, path: "provide.kind" },
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const resolvedKind = aliases.resolveKind(match.kind) ?? match.kind;
|
|
133
|
+
const targetDef = registry.resolve(resolvedKind) ?? registry.resolve(match.kind);
|
|
134
|
+
if (targetDef && targetDef.kind === "Telo.Definition") {
|
|
135
|
+
const targetCap = (targetDef as { capability?: unknown }).capability;
|
|
136
|
+
if (typeof targetCap === "string" && targetCap !== "Telo.Invocable") {
|
|
137
|
+
diagnostics.push({
|
|
138
|
+
severity: DiagnosticSeverity.Error,
|
|
139
|
+
code: "PROVIDE_TARGET_NOT_INVOCABLE",
|
|
140
|
+
source: SOURCE,
|
|
141
|
+
message:
|
|
142
|
+
`${label}: 'provide.name: ${providedName}' resolves to a ${match.kind} ` +
|
|
143
|
+
`(capability '${targetCap}'); 'provide:' requires a Telo.Invocable target.`,
|
|
144
|
+
data: { resource, filePath, path: "provide.name" },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (capability === "Telo.Provider" && !hasControllers && !hasProvide) {
|
|
153
|
+
diagnostics.push({
|
|
154
|
+
severity: DiagnosticSeverity.Error,
|
|
155
|
+
code: "PROVIDER_MISSING_IMPLEMENTATION",
|
|
156
|
+
source: SOURCE,
|
|
157
|
+
message:
|
|
158
|
+
`${label}: 'capability: Telo.Provider' requires either 'controllers:' ` +
|
|
159
|
+
`(TS-backed) or 'provide:' (template-backed) to declare an implementation.`,
|
|
160
|
+
data: { resource, filePath, path: "capability" },
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return diagnostics;
|
|
166
|
+
}
|