@telorun/analyzer 0.11.0 → 0.12.1
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/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 +181 -11
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +58 -1
- package/dist/definition-registry.d.ts +12 -1
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +36 -1
- 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 +22 -4
- package/dist/reference-field-map.d.ts.map +1 -1
- package/dist/reference-field-map.js +94 -26
- 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 +11 -1
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +25 -4
- 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 +5 -0
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +27 -15
- package/dist/validate-nested-inline.d.ts +30 -0
- package/dist/validate-nested-inline.d.ts.map +1 -0
- package/dist/validate-nested-inline.js +129 -0
- 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 +190 -13
- package/src/builtins.ts +58 -1
- package/src/definition-registry.ts +35 -1
- 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 +130 -25
- 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 +25 -4
- package/src/system-kinds.ts +37 -0
- package/src/types.ts +12 -0
- package/src/validate-cel-context.ts +28 -15
- package/src/validate-nested-inline.ts +158 -0
- 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,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
|
|
|
@@ -139,6 +146,10 @@ export function validateAgainstSchema(data: unknown, schema: Record<string, any>
|
|
|
139
146
|
validate = ajv.compile(schema);
|
|
140
147
|
compiledSchemaValidators.set(schema, validate);
|
|
141
148
|
} catch {
|
|
149
|
+
// A schema that won't compile is reported loudly (once, on the owning
|
|
150
|
+
// definition) by the analyzer's definition-schema compile check
|
|
151
|
+
// (`DefinitionRegistry.schemaCompileError`), so returning `[]` here does
|
|
152
|
+
// not silently accept resources of that kind.
|
|
142
153
|
return [];
|
|
143
154
|
}
|
|
144
155
|
}
|
|
@@ -266,7 +277,7 @@ export function celPlaceholderForSchema(schema: Record<string, any>): unknown {
|
|
|
266
277
|
const CEL_PURE_RE = /^\s*\$\{\{[^}]*\}\}\s*$/;
|
|
267
278
|
|
|
268
279
|
/** Resolve a `$ref` (only `#/$defs/...` form) against the root schema. */
|
|
269
|
-
function resolveRef(schema: Record<string, any>, root: Record<string, any>): Record<string, any> {
|
|
280
|
+
export function resolveRef(schema: Record<string, any>, root: Record<string, any>): Record<string, any> {
|
|
270
281
|
if (schema.$ref && typeof schema.$ref === "string" && schema.$ref.startsWith("#/$defs/")) {
|
|
271
282
|
const defName = schema.$ref.slice("#/$defs/".length);
|
|
272
283
|
const resolved = root.$defs?.[defName];
|
|
@@ -276,7 +287,7 @@ function resolveRef(schema: Record<string, any>, root: Record<string, any>): Rec
|
|
|
276
287
|
}
|
|
277
288
|
|
|
278
289
|
/** Collect property schemas from top-level `properties` and all `oneOf`/`anyOf` sub-schemas. */
|
|
279
|
-
function collectProperties(schema: Record<string, any>): Record<string, any> {
|
|
290
|
+
export function collectProperties(schema: Record<string, any>): Record<string, any> {
|
|
280
291
|
const props: Record<string, any> = { ...(schema.properties ?? {}) };
|
|
281
292
|
for (const sub of schema.oneOf ?? schema.anyOf ?? []) {
|
|
282
293
|
if (sub && typeof sub === "object" && sub.properties) {
|
|
@@ -301,6 +312,16 @@ export function substituteCelFields(
|
|
|
301
312
|
if (typeof data === "string" && CEL_PURE_RE.test(data)) {
|
|
302
313
|
return celPlaceholderForSchema(resolved);
|
|
303
314
|
}
|
|
315
|
+
// `!ref <name>` sentinels are identity markers, not runtime values —
|
|
316
|
+
// schemas that opt into `$ref: "telo://manifest#/$defs/ResourceRef"`
|
|
317
|
+
// (or `anyOf` it alongside other shapes) need the actual sentinel
|
|
318
|
+
// object so AJV validates it against ResourceRefSchema. Collapsing it
|
|
319
|
+
// to a CEL placeholder would either fail the schema (when the slot
|
|
320
|
+
// expects the ResourceRef shape) or mask validation errors (when the
|
|
321
|
+
// slot expects something else entirely).
|
|
322
|
+
if (isRefSentinel(data)) {
|
|
323
|
+
return data;
|
|
324
|
+
}
|
|
304
325
|
if (isTaggedSentinel(data)) {
|
|
305
326
|
return celPlaceholderForSchema(resolved);
|
|
306
327
|
}
|
|
@@ -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
|
|
@@ -119,6 +119,11 @@ export function pathMatchesScope(exprPath: string, scope: string): boolean {
|
|
|
119
119
|
* `manifestRoot.provide.kind` as a kind name, looks up the kind's Telo.Definition,
|
|
120
120
|
* and returns the `outputType` schema.
|
|
121
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.
|
|
126
|
+
*
|
|
122
127
|
* - `x-telo-context-ref-from`: existing form — reads `{kind, name}` object from
|
|
123
128
|
* `manifestItem.<path>`, looks up the named manifest, returns its `<subpath>` field.
|
|
124
129
|
*
|
|
@@ -153,8 +158,16 @@ export function resolveContextAnnotations(
|
|
|
153
158
|
}
|
|
154
159
|
|
|
155
160
|
const fromRoot = schema["x-telo-context-from-root"] as string | undefined;
|
|
156
|
-
const
|
|
157
|
-
|
|
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) {
|
|
158
171
|
if (fromRoot) {
|
|
159
172
|
const resolved = navigatePath(manifestRoot, fromRoot.split("/")) as
|
|
160
173
|
| Record<string, any>
|
|
@@ -163,22 +176,22 @@ export function resolveContextAnnotations(
|
|
|
163
176
|
return resolved;
|
|
164
177
|
}
|
|
165
178
|
}
|
|
166
|
-
if (
|
|
167
|
-
const
|
|
168
|
-
|
|
179
|
+
if (defs) {
|
|
180
|
+
for (const fromRefKind of fromRefKinds) {
|
|
181
|
+
const hashIdx = fromRefKind.indexOf("#");
|
|
182
|
+
if (hashIdx <= 0) continue;
|
|
169
183
|
const refPath = fromRefKind.slice(0, hashIdx);
|
|
170
184
|
const field = fromRefKind.slice(hashIdx + 1);
|
|
171
185
|
const kindValue = navigatePath(manifestRoot, refPath.split("/"));
|
|
172
|
-
if (typeof kindValue
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
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;
|
|
182
195
|
}
|
|
183
196
|
}
|
|
184
197
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { collectRefs, isInlineResource } from "./reference-field-map.js";
|
|
3
|
+
import {
|
|
4
|
+
collectProperties,
|
|
5
|
+
resolveRef,
|
|
6
|
+
substituteCelFields,
|
|
7
|
+
validateAgainstSchema,
|
|
8
|
+
} from "./schema-compat.js";
|
|
9
|
+
import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
|
|
10
|
+
|
|
11
|
+
const SOURCE = "telo-analyzer";
|
|
12
|
+
|
|
13
|
+
/** Minimal view of a definition needed to validate an inline resource's config. */
|
|
14
|
+
export interface InlineDefinitionLookup {
|
|
15
|
+
(kind: string): { schema?: Record<string, any> } | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Validates inline resources nested inside a resource body against their kind's
|
|
20
|
+
* config schema. The per-resource walk in `analyze()` validates a resource's
|
|
21
|
+
* own top-level config; inline resources at `x-telo-ref` slots reachable only
|
|
22
|
+
* through a local `$ref` (notably `Run.Sequence`'s `steps[].invoke`, hidden
|
|
23
|
+
* behind `#/$defs/step`) never reach the reference field map, so they would
|
|
24
|
+
* otherwise escape schema validation — e.g. `invoke: { kind: Console.ReadLine,
|
|
25
|
+
* prompt: "…" }`, where `prompt` belongs in the step's `inputs`, not the config.
|
|
26
|
+
*
|
|
27
|
+
* Walks the manifest data together with its definition schema, resolving local
|
|
28
|
+
* `$ref`s (so step trees of arbitrary depth are covered). At each `x-telo-ref`
|
|
29
|
+
* slot holding an inline resource, the inline's config is validated against its
|
|
30
|
+
* own kind's schema, then recursed into so inline resources nested inside
|
|
31
|
+
* inline resources are covered.
|
|
32
|
+
*
|
|
33
|
+
* Non-mutating: reads `manifest` and emits diagnostics anchored to its identity
|
|
34
|
+
* and a concrete dotted path matching the position-index key format;
|
|
35
|
+
* `rewriteSyntheticOrigins` reroutes those on inline-extracted (synthetic)
|
|
36
|
+
* manifests back to the root doc.
|
|
37
|
+
*/
|
|
38
|
+
export function validateNestedInlineResources(
|
|
39
|
+
manifest: ResourceManifest,
|
|
40
|
+
rootSchema: Record<string, any>,
|
|
41
|
+
lookupDefinition: InlineDefinitionLookup,
|
|
42
|
+
): AnalysisDiagnostic[] {
|
|
43
|
+
const diagnostics: AnalysisDiagnostic[] = [];
|
|
44
|
+
const resource = { kind: manifest.kind, name: manifest.metadata?.name as string };
|
|
45
|
+
const filePath = (manifest.metadata as { source?: string } | undefined)?.source;
|
|
46
|
+
|
|
47
|
+
function validateInline(inline: Record<string, any>, path: string): void {
|
|
48
|
+
const kind = inline.kind as string;
|
|
49
|
+
const def = lookupDefinition(kind);
|
|
50
|
+
// Unknown kind: these `$ref`-hidden slots are invisible to the field-map
|
|
51
|
+
// driven reference checks too, so nothing else would flag it — report here.
|
|
52
|
+
if (!def) {
|
|
53
|
+
diagnostics.push({
|
|
54
|
+
severity: DiagnosticSeverity.Error,
|
|
55
|
+
code: "UNDEFINED_KIND",
|
|
56
|
+
source: SOURCE,
|
|
57
|
+
message: `${resource.kind}/${resource.name}: inline ${kind} at '${path}': No Telo.Definition found for kind '${kind}'.`,
|
|
58
|
+
data: { resource, filePath, path: `${path}.kind` },
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// Kind exists but declares no config schema (e.g. a pure Telo.Type): no
|
|
63
|
+
// config to validate and no schema-declared slots to nest resources in.
|
|
64
|
+
if (!def.schema) return;
|
|
65
|
+
const schema = def.schema;
|
|
66
|
+
// `kind` / `metadata` are implicit on every resource; inject them so a
|
|
67
|
+
// strict `additionalProperties: false` config schema doesn't reject them.
|
|
68
|
+
const effectiveSchema =
|
|
69
|
+
schema.additionalProperties === false
|
|
70
|
+
? {
|
|
71
|
+
...schema,
|
|
72
|
+
properties: {
|
|
73
|
+
kind: { type: "string" },
|
|
74
|
+
metadata: { type: "object" },
|
|
75
|
+
...schema.properties,
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
: schema;
|
|
79
|
+
// Inline resources omit `metadata` — it is synthesized when the kernel
|
|
80
|
+
// registers them (and by `normalizeInlineResources` for extracted slots,
|
|
81
|
+
// which assigns a derived `metadata.name`). Config schemas conventionally
|
|
82
|
+
// declare `required: ["metadata", …]` with `metadata.name` required, so add
|
|
83
|
+
// a placeholder before validating to mirror the post-registration shape.
|
|
84
|
+
const existingMeta =
|
|
85
|
+
inline.metadata && typeof inline.metadata === "object"
|
|
86
|
+
? (inline.metadata as Record<string, unknown>)
|
|
87
|
+
: {};
|
|
88
|
+
const data = { ...inline, metadata: { name: "__inline__", ...existingMeta } };
|
|
89
|
+
const substituted = substituteCelFields(data, effectiveSchema, effectiveSchema);
|
|
90
|
+
for (const issue of validateAgainstSchema(substituted, effectiveSchema)) {
|
|
91
|
+
diagnostics.push({
|
|
92
|
+
severity: DiagnosticSeverity.Error,
|
|
93
|
+
code: "SCHEMA_VIOLATION",
|
|
94
|
+
source: SOURCE,
|
|
95
|
+
message: `${resource.kind}/${resource.name}: inline ${kind} at '${path}': ${issue.message}`,
|
|
96
|
+
data: { resource, filePath, path: issue.path ? `${path}.${issue.path}` : path },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
// Recurse into the inline body against its own schema so deeper inline
|
|
100
|
+
// resources (e.g. an inline Run.Sequence's own steps) are validated too.
|
|
101
|
+
walk(inline, schema, schema, path);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function walk(
|
|
105
|
+
data: unknown,
|
|
106
|
+
schema: Record<string, any> | undefined,
|
|
107
|
+
schemaRoot: Record<string, any>,
|
|
108
|
+
path: string,
|
|
109
|
+
): void {
|
|
110
|
+
if (!schema || typeof schema !== "object") return;
|
|
111
|
+
const resolved = resolveRef(schema, schemaRoot);
|
|
112
|
+
|
|
113
|
+
// Reference slot: the value is either a named reference (`{kind, name}`,
|
|
114
|
+
// validated as its own manifest) or an inline resource to validate here.
|
|
115
|
+
if (collectRefs(resolved).length > 0) {
|
|
116
|
+
if (
|
|
117
|
+
data &&
|
|
118
|
+
typeof data === "object" &&
|
|
119
|
+
!Array.isArray(data) &&
|
|
120
|
+
isInlineResource(data as Record<string, unknown>)
|
|
121
|
+
) {
|
|
122
|
+
validateInline(data as Record<string, any>, path);
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (Array.isArray(data)) {
|
|
128
|
+
const itemSchema = resolved.items as Record<string, any> | undefined;
|
|
129
|
+
if (!itemSchema) return;
|
|
130
|
+
for (let i = 0; i < data.length; i++) {
|
|
131
|
+
walk(data[i], itemSchema, schemaRoot, `${path}[${i}]`);
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (data && typeof data === "object") {
|
|
137
|
+
const props = collectProperties(resolved);
|
|
138
|
+
const additional =
|
|
139
|
+
resolved.additionalProperties &&
|
|
140
|
+
typeof resolved.additionalProperties === "object" &&
|
|
141
|
+
!Array.isArray(resolved.additionalProperties)
|
|
142
|
+
? (resolved.additionalProperties as Record<string, any>)
|
|
143
|
+
: undefined;
|
|
144
|
+
// Descend only where the schema declares structure. Freeform fields
|
|
145
|
+
// (`additionalProperties: true`, e.g. step `inputs`) carry caller data
|
|
146
|
+
// that may coincidentally look like `{kind: …}`; not descending there
|
|
147
|
+
// keeps the inline-resource detection anchored to real ref slots.
|
|
148
|
+
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
|
|
149
|
+
const propSchema = props[key] ?? additional;
|
|
150
|
+
if (!propSchema) continue;
|
|
151
|
+
walk(value, propSchema as Record<string, any>, schemaRoot, path ? `${path}.${key}` : key);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
walk(manifest, rootSchema, rootSchema, "");
|
|
157
|
+
return diagnostics;
|
|
158
|
+
}
|
|
@@ -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
|
+
}
|