@telorun/analyzer 0.33.0 → 0.34.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/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +10 -9
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +21 -9
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +15 -0
- package/dist/definition-registry.d.ts +4 -1
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +20 -3
- package/dist/extends-resolution.d.ts +33 -0
- package/dist/extends-resolution.d.ts.map +1 -0
- package/dist/extends-resolution.js +82 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/validate-base-mapping.d.ts +21 -0
- package/dist/validate-base-mapping.d.ts.map +1 -0
- package/dist/validate-base-mapping.js +136 -0
- package/dist/validate-extends.d.ts.map +1 -1
- package/dist/validate-extends.js +20 -9
- package/dist/validate-provider-coherence.d.ts.map +1 -1
- package/dist/validate-provider-coherence.js +8 -1
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +15 -9
- package/package.json +2 -2
- package/src/analysis-registry.ts +9 -8
- package/src/analyzer.ts +32 -9
- package/src/builtins.ts +15 -0
- package/src/definition-registry.ts +18 -3
- package/src/extends-resolution.ts +124 -0
- package/src/index.ts +12 -0
- package/src/validate-base-mapping.ts +159 -0
- package/src/validate-extends.ts +24 -11
- package/src/validate-provider-coherence.ts +12 -2
- package/src/validate-references.ts +13 -9
|
@@ -21,20 +21,26 @@ function checkKind(kind, entry, registry, aliases) {
|
|
|
21
21
|
const targetDef = registry.resolve(targetKind);
|
|
22
22
|
if (!targetDef)
|
|
23
23
|
return [];
|
|
24
|
+
// Liskov substitutability: a value satisfies the slot when it transitively
|
|
25
|
+
// extends the target kind, or — for a CONCRETE target — IS that kind.
|
|
26
|
+
// `getByExtends` is the same transitive subtype index for abstract and
|
|
27
|
+
// concrete targets alike; an abstract is satisfied only by an implementer,
|
|
28
|
+
// never by the abstract kind itself (which is non-instantiable).
|
|
29
|
+
if (targetDef.kind !== "Telo.Abstract" && resolved === targetKind)
|
|
30
|
+
return [];
|
|
31
|
+
const subtypes = registry.getByExtends(targetKind);
|
|
32
|
+
const subtypeKinds = new Set(subtypes.map((d) => `${d.metadata.module}.${d.metadata.name}`));
|
|
33
|
+
if (subtypeKinds.has(resolved))
|
|
34
|
+
return [];
|
|
24
35
|
if (targetDef.kind === "Telo.Abstract") {
|
|
25
|
-
|
|
26
|
-
if (implementing.length === 0)
|
|
36
|
+
if (subtypes.length === 0)
|
|
27
37
|
return []; // partial context — no implementations loaded yet
|
|
28
|
-
const
|
|
29
|
-
if (implementingKinds.has(resolved))
|
|
30
|
-
return [];
|
|
31
|
-
const options = [...implementingKinds].join(", ");
|
|
38
|
+
const options = [...subtypeKinds].join(", ");
|
|
32
39
|
errors.push(`'${kind}' does not implement '${targetKind}' (known implementations: ${options})`);
|
|
33
40
|
}
|
|
34
41
|
else {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
errors.push(`'${kind}' (resolved: '${resolved}') does not match required '${targetKind}'`);
|
|
42
|
+
const options = subtypeKinds.size > 0 ? ` or a subtype (${[...subtypeKinds].join(", ")})` : "";
|
|
43
|
+
errors.push(`'${kind}' (resolved: '${resolved}') does not match required '${targetKind}'${options}`);
|
|
38
44
|
}
|
|
39
45
|
}
|
|
40
46
|
return errors;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/analyzer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.1",
|
|
4
4
|
"description": "Telo Analyzer - Static manifest validator for Telo manifests.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@types/node": "^20.0.0",
|
|
49
49
|
"typescript": "^5.0.0",
|
|
50
50
|
"vitest": "^2.1.8",
|
|
51
|
-
"@telorun/sdk": "0.
|
|
51
|
+
"@telorun/sdk": "0.44.0"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"@telorun/sdk": "*"
|
package/src/analysis-registry.ts
CHANGED
|
@@ -262,16 +262,17 @@ export class AnalysisRegistry {
|
|
|
262
262
|
const targetDef = this.defs.resolve(targetKind);
|
|
263
263
|
if (!targetDef) return undefined;
|
|
264
264
|
|
|
265
|
+
// General single inheritance: the accepted set is the target kind plus every
|
|
266
|
+
// kind that transitively extends it (subtypes are substitutable). A concrete
|
|
267
|
+
// target contributes itself and any specializations; an abstract contributes
|
|
268
|
+
// its implementations. Same transitive index for both.
|
|
265
269
|
const out = new Set<string>();
|
|
266
|
-
if (targetDef.kind
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
270
|
+
if (targetDef.kind !== "Telo.Abstract") out.add(targetKind);
|
|
271
|
+
for (const def of this.defs.getByExtends(targetKind)) {
|
|
272
|
+
const module = (def.metadata as { module?: string } | undefined)?.module;
|
|
273
|
+
if (module && def.metadata?.name) {
|
|
274
|
+
out.add(`${module}.${def.metadata.name as string}`);
|
|
272
275
|
}
|
|
273
|
-
} else {
|
|
274
|
-
out.add(targetKind);
|
|
275
276
|
}
|
|
276
277
|
return out;
|
|
277
278
|
}
|
package/src/analyzer.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type CelHandlers,
|
|
12
12
|
} from "./cel-environment.js";
|
|
13
13
|
import { DefinitionRegistry } from "./definition-registry.js";
|
|
14
|
+
import { effectiveAuthorSchema } from "./extends-resolution.js";
|
|
14
15
|
import { buildDependencyGraph, formatCycle } from "./dependency-graph.js";
|
|
15
16
|
import { buildKernelGlobalsSchema, mergeKernelGlobalsIntoContext } from "./kernel-globals.js";
|
|
16
17
|
import { computeSuggestKind } from "./kind-suggest.js";
|
|
@@ -39,6 +40,7 @@ import {
|
|
|
39
40
|
} from "./validate-cel-context.js";
|
|
40
41
|
import { buildEvalPaths, evalPathsCover } from "./eval-paths.js";
|
|
41
42
|
import { validateExtends } from "./validate-extends.js";
|
|
43
|
+
import { validateBaseMapping } from "./validate-base-mapping.js";
|
|
42
44
|
import { validateNestedInlineResources } from "./validate-nested-inline.js";
|
|
43
45
|
import { validateKindDescriptions } from "./validate-kind-descriptions.js";
|
|
44
46
|
import { validateProviderCoherence } from "./validate-provider-coherence.js";
|
|
@@ -123,8 +125,20 @@ const SOURCE = "telo-analyzer";
|
|
|
123
125
|
* property the user declared in `schema:` plus synthetic `name` / `kind` and
|
|
124
126
|
* the metadata sub-object (kept open since metadata legitimately carries
|
|
125
127
|
* arbitrary user-added fields). */
|
|
126
|
-
function buildSelfSchema(
|
|
127
|
-
|
|
128
|
+
function buildSelfSchema(
|
|
129
|
+
definition: Record<string, any>,
|
|
130
|
+
defs?: DefinitionRegistry,
|
|
131
|
+
aliases?: AliasResolver,
|
|
132
|
+
): Record<string, any> {
|
|
133
|
+
// The author-facing schema resolves inheritance: with `base:` the child's own
|
|
134
|
+
// schema (the parent's config is internal); without it, `merge(parent, own)`.
|
|
135
|
+
const userSchema = (
|
|
136
|
+
defs
|
|
137
|
+
? effectiveAuthorSchema(definition as unknown as ResourceDefinition, (k) =>
|
|
138
|
+
defs.resolve(aliases?.resolveKind(k) ?? k) ?? defs.resolve(k),
|
|
139
|
+
)
|
|
140
|
+
: (definition.schema ?? {})
|
|
141
|
+
) as Record<string, any>;
|
|
128
142
|
const userProps = (userSchema.properties ?? {}) as Record<string, any>;
|
|
129
143
|
const userRequired = Array.isArray(userSchema.required) ? userSchema.required : [];
|
|
130
144
|
return {
|
|
@@ -193,7 +207,7 @@ function manifestRootForResolver(
|
|
|
193
207
|
const inputs = lookupTemplateInputsSchema(m, defs, aliases, allManifests);
|
|
194
208
|
return {
|
|
195
209
|
...m,
|
|
196
|
-
schema: buildSelfSchema(m),
|
|
210
|
+
schema: buildSelfSchema(m, defs, aliases),
|
|
197
211
|
...(inputs ? { inputType: inputs } : {}),
|
|
198
212
|
};
|
|
199
213
|
}
|
|
@@ -1230,21 +1244,28 @@ export class StaticAnalyzer {
|
|
|
1230
1244
|
continue;
|
|
1231
1245
|
}
|
|
1232
1246
|
|
|
1233
|
-
// Validate resource config against definition schema
|
|
1247
|
+
// Validate resource config against the definition's AUTHOR-FACING schema —
|
|
1248
|
+
// inheritance-resolved: with `base:` the child's own schema (parent config
|
|
1249
|
+
// is internal), else `merge(parent, own)` so a `base:`-less `extends` child
|
|
1250
|
+
// is validated against the inherited fields it may set. For a definition
|
|
1251
|
+
// that neither extends nor uses `base:` this is exactly its own schema.
|
|
1234
1252
|
// `kind` and `metadata` are implicit on every resource — inject them so module
|
|
1235
1253
|
// authors don't have to repeat them when using additionalProperties: false.
|
|
1236
|
-
|
|
1254
|
+
const authorSchema = effectiveAuthorSchema(definition, (k) =>
|
|
1255
|
+
defs.resolve(aliases.resolveKind(k) ?? k) ?? defs.resolve(k),
|
|
1256
|
+
);
|
|
1257
|
+
if (authorSchema && Object.keys(authorSchema).length > 0) {
|
|
1237
1258
|
const schema =
|
|
1238
|
-
|
|
1259
|
+
authorSchema.additionalProperties === false
|
|
1239
1260
|
? {
|
|
1240
|
-
...
|
|
1261
|
+
...authorSchema,
|
|
1241
1262
|
properties: {
|
|
1242
1263
|
kind: { type: "string" },
|
|
1243
1264
|
metadata: { type: "object" },
|
|
1244
|
-
...
|
|
1265
|
+
...authorSchema.properties,
|
|
1245
1266
|
},
|
|
1246
1267
|
}
|
|
1247
|
-
:
|
|
1268
|
+
: authorSchema;
|
|
1248
1269
|
// Phase 1: CEL type checking — walk data+schema together, check env.check() return types.
|
|
1249
1270
|
// A Telo.Import's variables/secrets are a config-only contract evaluated against the
|
|
1250
1271
|
// IMPORTING module's scope, so type them from the owning module doc (matched by
|
|
@@ -1628,6 +1649,8 @@ export class StaticAnalyzer {
|
|
|
1628
1649
|
// Validate `extends` fields and flag legacy `capability: <UserAbstract>` overload.
|
|
1629
1650
|
diagnostics.push(...validateExtends(allManifests, defs, aliases));
|
|
1630
1651
|
|
|
1652
|
+
diagnostics.push(...validateBaseMapping(allManifests, defs, aliases));
|
|
1653
|
+
|
|
1631
1654
|
// Validate provider coherence rules for `provide:` template-target definitions.
|
|
1632
1655
|
diagnostics.push(...validateProviderCoherence(allManifests, defs, aliases));
|
|
1633
1656
|
|
package/src/builtins.ts
CHANGED
|
@@ -209,6 +209,21 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
|
|
|
209
209
|
},
|
|
210
210
|
},
|
|
211
211
|
},
|
|
212
|
+
// `base:` ("super(...)") — construction mapping for an inherited
|
|
213
|
+
// (concrete-`extends`) definition. Its CEL is evaluated once against
|
|
214
|
+
// `self` (typed from this definition's `schema:`) to build the parent
|
|
215
|
+
// kind's config. Same `self`-only scope as a resource body.
|
|
216
|
+
base: {
|
|
217
|
+
type: "object",
|
|
218
|
+
additionalProperties: true,
|
|
219
|
+
"x-telo-context": {
|
|
220
|
+
type: "object",
|
|
221
|
+
additionalProperties: false,
|
|
222
|
+
properties: {
|
|
223
|
+
self: { "x-telo-context-from-root": "schema" },
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
},
|
|
212
227
|
},
|
|
213
228
|
},
|
|
214
229
|
},
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type ReferenceFieldMap,
|
|
9
9
|
} from "./reference-field-map.js";
|
|
10
10
|
import { createAjv, formatSingleError, navigateJsonPointer } from "./schema-compat.js";
|
|
11
|
+
import { effectiveAuthorSchema } from "./extends-resolution.js";
|
|
11
12
|
|
|
12
13
|
/** Pure kind → ResourceDefinition map. No controller loading, no lifecycle. */
|
|
13
14
|
export class DefinitionRegistry {
|
|
@@ -37,7 +38,11 @@ export class DefinitionRegistry {
|
|
|
37
38
|
const { name, module: mod } = definition.metadata;
|
|
38
39
|
const key = mod ? `${mod}.${name}` : name;
|
|
39
40
|
this.defs.set(key, definition);
|
|
40
|
-
|
|
41
|
+
// Field maps derive from the AUTHOR-FACING (inheritance-resolved) schema, which
|
|
42
|
+
// depends on the parent — possibly registered after this child. Clear the cache
|
|
43
|
+
// so any already-computed map recomputes against the now-larger def set; the
|
|
44
|
+
// maps rebuild lazily on first `getFieldMap` (after all defs are registered).
|
|
45
|
+
this.fieldMaps.clear();
|
|
41
46
|
// `capability` populates extendedBy for backward-compat with the legacy pattern where
|
|
42
47
|
// a concrete definition overloaded `capability: <AbstractKind>` to mean "implements
|
|
43
48
|
// this abstract." The canonical pattern is `extends` (below). Both populate the index,
|
|
@@ -204,9 +209,19 @@ export class DefinitionRegistry {
|
|
|
204
209
|
return this.defs.get(kind);
|
|
205
210
|
}
|
|
206
211
|
|
|
207
|
-
/** Returns the
|
|
212
|
+
/** Returns the reference field map for the given kind, computed lazily from the
|
|
213
|
+
* kind's AUTHOR-FACING (inheritance-resolved) schema and memoized. Lazy so a
|
|
214
|
+
* child registered before its parent still sees the parent's inherited ref
|
|
215
|
+
* slots once both are present. */
|
|
208
216
|
getFieldMap(kind: string): ReferenceFieldMap | undefined {
|
|
209
|
-
|
|
217
|
+
const cached = this.fieldMaps.get(kind);
|
|
218
|
+
if (cached) return cached;
|
|
219
|
+
const def = this.defs.get(kind);
|
|
220
|
+
if (!def) return undefined;
|
|
221
|
+
const schema = effectiveAuthorSchema(def, (k) => this.resolve(k));
|
|
222
|
+
const map = buildReferenceFieldMap(schema ?? {});
|
|
223
|
+
this.fieldMaps.set(kind, map);
|
|
224
|
+
return map;
|
|
210
225
|
}
|
|
211
226
|
|
|
212
227
|
/** Returns the field map for `kind`, falling back to the alias-resolved kind when not found. */
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { ResourceDefinition } from "@telorun/sdk";
|
|
2
|
+
import { mergeTypeSchemas } from "@telorun/sdk";
|
|
3
|
+
|
|
4
|
+
/** Resolves a kind string (canonical or alias form, depending on the caller's
|
|
5
|
+
* registry) to its `Telo.Definition` / `Telo.Abstract`, or undefined. */
|
|
6
|
+
export type DefResolver = (kind: string) => ResourceDefinition | undefined;
|
|
7
|
+
|
|
8
|
+
/** The template-body / controller fields a definition may carry. Kept local
|
|
9
|
+
* because `ResourceDefinition` intentionally types only the stable surface;
|
|
10
|
+
* template bodies are read structurally. */
|
|
11
|
+
interface DefinitionBody {
|
|
12
|
+
extends?: string;
|
|
13
|
+
capability?: string;
|
|
14
|
+
controllers?: unknown[];
|
|
15
|
+
invoke?: unknown;
|
|
16
|
+
run?: unknown;
|
|
17
|
+
provide?: unknown;
|
|
18
|
+
mount?: unknown;
|
|
19
|
+
resources?: unknown[];
|
|
20
|
+
base?: Record<string, unknown>;
|
|
21
|
+
schema?: Record<string, any>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const body = (def: ResourceDefinition | undefined): DefinitionBody =>
|
|
25
|
+
(def ?? {}) as unknown as DefinitionBody;
|
|
26
|
+
|
|
27
|
+
/** The definition a given definition directly `extends`, or undefined when it
|
|
28
|
+
* extends nothing / the target can't be resolved. */
|
|
29
|
+
export function resolveParent(
|
|
30
|
+
def: ResourceDefinition | undefined,
|
|
31
|
+
resolve: DefResolver,
|
|
32
|
+
): ResourceDefinition | undefined {
|
|
33
|
+
const ext = body(def).extends;
|
|
34
|
+
if (typeof ext !== "string" || ext.length === 0) return undefined;
|
|
35
|
+
return resolve(ext);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The `extends` ancestor chain, nearest-first, excluding `def` itself.
|
|
39
|
+
* Cycle-guarded so a malformed self/mutual `extends` can't loop forever. */
|
|
40
|
+
export function ancestorChain(
|
|
41
|
+
def: ResourceDefinition | undefined,
|
|
42
|
+
resolve: DefResolver,
|
|
43
|
+
): ResourceDefinition[] {
|
|
44
|
+
const chain: ResourceDefinition[] = [];
|
|
45
|
+
const seen = new Set<ResourceDefinition>();
|
|
46
|
+
let cur = resolveParent(def, resolve);
|
|
47
|
+
while (cur && !seen.has(cur)) {
|
|
48
|
+
seen.add(cur);
|
|
49
|
+
chain.push(cur);
|
|
50
|
+
cur = resolveParent(cur, resolve);
|
|
51
|
+
}
|
|
52
|
+
return chain;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** True when a definition carries its own controller (`controllers:`) or a
|
|
56
|
+
* template body (`invoke:` / `run:` / `provide:` / `mount:` / `resources:`). */
|
|
57
|
+
export function hasOwnControllerOrTemplate(def: ResourceDefinition | undefined): boolean {
|
|
58
|
+
const d = body(def);
|
|
59
|
+
return !!(
|
|
60
|
+
(d.controllers && d.controllers.length) ||
|
|
61
|
+
d.invoke ||
|
|
62
|
+
d.run ||
|
|
63
|
+
d.provide ||
|
|
64
|
+
d.mount ||
|
|
65
|
+
d.resources
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The nearest concrete ancestor that provides a controller (own `controllers:`
|
|
70
|
+
* or a template body) — the definition whose controller an inherited child
|
|
71
|
+
* delegates to. Undefined when no controller-bearing concrete ancestor exists. */
|
|
72
|
+
export function controllerBearingAncestor(
|
|
73
|
+
def: ResourceDefinition | undefined,
|
|
74
|
+
resolve: DefResolver,
|
|
75
|
+
): ResourceDefinition | undefined {
|
|
76
|
+
for (const a of ancestorChain(def, resolve)) {
|
|
77
|
+
if (a.kind === "Telo.Abstract") continue;
|
|
78
|
+
if (hasOwnControllerOrTemplate(a)) return a;
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** True when this definition inherits its controller by delegation: it declares
|
|
84
|
+
* `extends`, has no own controller/template body, and its nearest concrete
|
|
85
|
+
* ancestor is controller-bearing. */
|
|
86
|
+
export function isInheritedDelegation(
|
|
87
|
+
def: ResourceDefinition | undefined,
|
|
88
|
+
resolve: DefResolver,
|
|
89
|
+
): boolean {
|
|
90
|
+
if (!body(def).extends || hasOwnControllerOrTemplate(def)) return false;
|
|
91
|
+
return controllerBearingAncestor(def, resolve) !== undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The effective (possibly inherited) capability: the nearest self-or-ancestor
|
|
95
|
+
* that declares a `capability`. Undefined when none in the chain does. */
|
|
96
|
+
export function inheritedCapability(
|
|
97
|
+
def: ResourceDefinition | undefined,
|
|
98
|
+
resolve: DefResolver,
|
|
99
|
+
): string | undefined {
|
|
100
|
+
if (body(def).capability) return body(def).capability;
|
|
101
|
+
for (const a of ancestorChain(def, resolve)) {
|
|
102
|
+
if (body(a).capability) return body(a).capability;
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The author-facing schema for a definition:
|
|
108
|
+
* - with `base:` present → the definition's **own** schema (the parent's config
|
|
109
|
+
* fields are internal, set solely through `base:`).
|
|
110
|
+
* - without `base:` but with `extends` → `merge(parent-effective, own)` (a pure
|
|
111
|
+
* additive extension; child overrides on key conflicts), reusing the same
|
|
112
|
+
* `mergeTypeSchemas` that `Type.JsonSchema.extends` uses.
|
|
113
|
+
* - no `extends` → the own schema unchanged. */
|
|
114
|
+
export function effectiveAuthorSchema(
|
|
115
|
+
def: ResourceDefinition | undefined,
|
|
116
|
+
resolve: DefResolver,
|
|
117
|
+
): Record<string, any> {
|
|
118
|
+
const own = (body(def).schema ?? {}) as Record<string, any>;
|
|
119
|
+
const parent = resolveParent(def, resolve);
|
|
120
|
+
if (!parent) return own;
|
|
121
|
+
if (body(def).base) return own;
|
|
122
|
+
const parentSchema = effectiveAuthorSchema(parent, resolve);
|
|
123
|
+
return mergeTypeSchemas([parentSchema, own]) as Record<string, any>;
|
|
124
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,18 @@ export {
|
|
|
22
22
|
type ReExportSpec,
|
|
23
23
|
} from "./flatten-for-analyzer.js";
|
|
24
24
|
export { buildEvalPaths, evalPathCovers } from "./eval-paths.js";
|
|
25
|
+
export {
|
|
26
|
+
ancestorChain,
|
|
27
|
+
controllerBearingAncestor,
|
|
28
|
+
effectiveAuthorSchema,
|
|
29
|
+
hasOwnControllerOrTemplate,
|
|
30
|
+
inheritedCapability,
|
|
31
|
+
isInheritedDelegation,
|
|
32
|
+
resolveParent,
|
|
33
|
+
} from "./extends-resolution.js";
|
|
34
|
+
export type { DefResolver } from "./extends-resolution.js";
|
|
35
|
+
export { buildReferenceFieldMap, isRefEntry, isScopeEntry } from "./reference-field-map.js";
|
|
36
|
+
export type { ReferenceFieldMap, RefFieldEntry } from "./reference-field-map.js";
|
|
25
37
|
export { visitManifest } from "./manifest-visitor.js";
|
|
26
38
|
export type {
|
|
27
39
|
CelSiteEvent,
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { isCompiledValue } from "@telorun/sdk";
|
|
3
|
+
import { isTaggedSentinel } from "@telorun/templating";
|
|
4
|
+
import type { AliasResolver } from "./alias-resolver.js";
|
|
5
|
+
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
6
|
+
import { effectiveAuthorSchema, resolveParent, type DefResolver } from "./extends-resolution.js";
|
|
7
|
+
import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
|
|
8
|
+
|
|
9
|
+
const SOURCE = "telo-analyzer";
|
|
10
|
+
|
|
11
|
+
/** True when a value subtree contains a CEL leaf — either a compiled CEL value
|
|
12
|
+
* (post-precompile) or the raw `!cel` tagged sentinel the analyzer sees at
|
|
13
|
+
* `telo check` time (this validator runs on the un-precompiled tree). Such a
|
|
14
|
+
* value can produce anything at runtime, so its type is not statically
|
|
15
|
+
* checkable. */
|
|
16
|
+
function containsCel(value: unknown): boolean {
|
|
17
|
+
if (isCompiledValue(value)) return true;
|
|
18
|
+
if (isTaggedSentinel(value) && value.engine === "cel") return true;
|
|
19
|
+
if (Array.isArray(value)) return value.some(containsCel);
|
|
20
|
+
if (value && typeof value === "object") {
|
|
21
|
+
return Object.values(value as Record<string, unknown>).some(containsCel);
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Phase 3c — Static validation of a definition's `base:` construction mapping
|
|
28
|
+
* against the parent kind's config schema. The kernel evaluates `base:` and
|
|
29
|
+
* passes the result to the inherited controller's `create()`, which validates it
|
|
30
|
+
* at boot; this mirrors that check statically so an omitted required field or a
|
|
31
|
+
* wrong literal type surfaces at `telo check`, not first boot.
|
|
32
|
+
*
|
|
33
|
+
* Diagnostics:
|
|
34
|
+
* - BASE_MISSING_REQUIRED: `base:` omits a field the parent schema requires.
|
|
35
|
+
* - BASE_UNKNOWN_FIELD: `base:` sets a field the parent schema (with
|
|
36
|
+
* `additionalProperties: false`) does not declare.
|
|
37
|
+
* - BASE_SCHEMA_MISMATCH: a CEL-free `base:` value violates the parent field's
|
|
38
|
+
* schema (wrong type / constraint). CEL-bearing values are skipped — their
|
|
39
|
+
* runtime value is unknown — but still count as present for required checks.
|
|
40
|
+
*/
|
|
41
|
+
export function validateBaseMapping(
|
|
42
|
+
manifests: ResourceManifest[],
|
|
43
|
+
registry: DefinitionRegistry,
|
|
44
|
+
aliases: AliasResolver,
|
|
45
|
+
): AnalysisDiagnostic[] {
|
|
46
|
+
const diagnostics: AnalysisDiagnostic[] = [];
|
|
47
|
+
const resolveDef: DefResolver = (k) =>
|
|
48
|
+
registry.resolve(aliases.resolveKind(k) ?? k) ?? registry.resolve(k);
|
|
49
|
+
|
|
50
|
+
const importedModules = new Set<string>();
|
|
51
|
+
for (const m of manifests) {
|
|
52
|
+
if (m.kind !== "Telo.Import") continue;
|
|
53
|
+
const resolved = (m.metadata as { resolvedModuleName?: string } | undefined)?.resolvedModuleName;
|
|
54
|
+
if (resolved) importedModules.add(resolved);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const m of manifests) {
|
|
58
|
+
if (m.kind !== "Telo.Definition") continue;
|
|
59
|
+
const base = (m as { base?: unknown }).base;
|
|
60
|
+
if (!base || typeof base !== "object" || Array.isArray(base)) continue;
|
|
61
|
+
const name = m.metadata?.name as string | undefined;
|
|
62
|
+
if (!name) continue;
|
|
63
|
+
const ownModule = (m.metadata as { module?: string } | undefined)?.module;
|
|
64
|
+
if (ownModule && importedModules.has(ownModule)) continue;
|
|
65
|
+
|
|
66
|
+
const parent = resolveParent(m as unknown as ResourceDefinition, resolveDef);
|
|
67
|
+
// Missing / unresolved `extends` is already reported by validateExtends.
|
|
68
|
+
if (!parent) continue;
|
|
69
|
+
const parentSchema = effectiveAuthorSchema(parent, resolveDef);
|
|
70
|
+
if (!parentSchema || typeof parentSchema !== "object") continue;
|
|
71
|
+
|
|
72
|
+
const filePath = (m.metadata as { source?: string } | undefined)?.source;
|
|
73
|
+
const resource = { kind: m.kind, name };
|
|
74
|
+
const label = `${m.kind}/${name}`;
|
|
75
|
+
checkObject(base as Record<string, unknown>, parentSchema, "base", {
|
|
76
|
+
diagnostics,
|
|
77
|
+
registry,
|
|
78
|
+
label,
|
|
79
|
+
resource,
|
|
80
|
+
filePath,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return diagnostics;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface CheckCtx {
|
|
88
|
+
diagnostics: AnalysisDiagnostic[];
|
|
89
|
+
registry: DefinitionRegistry;
|
|
90
|
+
label: string;
|
|
91
|
+
resource: { kind: string; name: string };
|
|
92
|
+
filePath: string | undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function checkObject(
|
|
96
|
+
value: Record<string, unknown>,
|
|
97
|
+
schema: Record<string, any>,
|
|
98
|
+
path: string,
|
|
99
|
+
ctx: CheckCtx,
|
|
100
|
+
): void {
|
|
101
|
+
const properties = (schema.properties ?? {}) as Record<string, Record<string, any>>;
|
|
102
|
+
const required = Array.isArray(schema.required) ? (schema.required as string[]) : [];
|
|
103
|
+
const additionalFalse = schema.additionalProperties === false;
|
|
104
|
+
|
|
105
|
+
for (const req of required) {
|
|
106
|
+
if (!(req in value)) {
|
|
107
|
+
ctx.diagnostics.push({
|
|
108
|
+
severity: DiagnosticSeverity.Error,
|
|
109
|
+
code: "BASE_MISSING_REQUIRED",
|
|
110
|
+
source: SOURCE,
|
|
111
|
+
message: `${ctx.label}: '${path}' does not set required parent field '${req}'.`,
|
|
112
|
+
data: { resource: ctx.resource, filePath: ctx.filePath, path },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const [key, fieldValue] of Object.entries(value)) {
|
|
118
|
+
const fieldPath = `${path}.${key}`;
|
|
119
|
+
const propSchema = properties[key];
|
|
120
|
+
if (!propSchema) {
|
|
121
|
+
if (additionalFalse) {
|
|
122
|
+
ctx.diagnostics.push({
|
|
123
|
+
severity: DiagnosticSeverity.Error,
|
|
124
|
+
code: "BASE_UNKNOWN_FIELD",
|
|
125
|
+
source: SOURCE,
|
|
126
|
+
message: `${ctx.label}: '${fieldPath}' is not a field of the parent kind's schema.`,
|
|
127
|
+
data: { resource: ctx.resource, filePath: ctx.filePath, path: fieldPath },
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
// A CEL-bearing value produces its shape at runtime — not statically
|
|
133
|
+
// checkable. Recurse into a partially-CEL nested object so its literal
|
|
134
|
+
// sub-fields still get validated; fully-literal values validate directly.
|
|
135
|
+
if (containsCel(fieldValue)) {
|
|
136
|
+
if (
|
|
137
|
+
fieldValue &&
|
|
138
|
+
typeof fieldValue === "object" &&
|
|
139
|
+
!Array.isArray(fieldValue) &&
|
|
140
|
+
!isCompiledValue(fieldValue) &&
|
|
141
|
+
propSchema.type === "object" &&
|
|
142
|
+
propSchema.properties
|
|
143
|
+
) {
|
|
144
|
+
checkObject(fieldValue as Record<string, unknown>, propSchema, fieldPath, ctx);
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const issues = ctx.registry.validateWithRefs(fieldValue, propSchema);
|
|
149
|
+
for (const issue of issues) {
|
|
150
|
+
ctx.diagnostics.push({
|
|
151
|
+
severity: DiagnosticSeverity.Error,
|
|
152
|
+
code: "BASE_SCHEMA_MISMATCH",
|
|
153
|
+
source: SOURCE,
|
|
154
|
+
message: `${ctx.label}: '${fieldPath}' does not match the parent field's schema: ${issue}`,
|
|
155
|
+
data: { resource: ctx.resource, filePath: ctx.filePath, path: fieldPath },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
package/src/validate-extends.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { ResourceManifest } from "@telorun/sdk";
|
|
1
|
+
import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
2
2
|
import type { AliasResolver } from "./alias-resolver.js";
|
|
3
3
|
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
4
|
+
import { inheritedCapability, type DefResolver } from "./extends-resolution.js";
|
|
4
5
|
import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
|
|
5
6
|
|
|
6
7
|
const SOURCE = "telo-analyzer";
|
|
@@ -119,16 +120,28 @@ export function validateExtends(
|
|
|
119
120
|
message: `${label}: 'extends' target '${extendsValue}' (resolved: '${canonical}') is not a registered definition.`,
|
|
120
121
|
data: { resource, filePath, path: "extends" },
|
|
121
122
|
});
|
|
122
|
-
} else
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
123
|
+
} else {
|
|
124
|
+
// General single inheritance: any concrete or abstract kind may be
|
|
125
|
+
// extended. What inheritance must NOT do is change the lifecycle
|
|
126
|
+
// role — a child that restates `capability` differently from an
|
|
127
|
+
// ancestor is a hard error (no silent capability change).
|
|
128
|
+
const resolveDef: DefResolver = (k) =>
|
|
129
|
+
registry.resolve(aliases.resolveKind(k) ?? k) ?? registry.resolve(k);
|
|
130
|
+
const ownCap = (m as { capability?: unknown }).capability;
|
|
131
|
+
const ownCapResolved =
|
|
132
|
+
typeof ownCap === "string" ? aliases.resolveKind(ownCap) ?? ownCap : undefined;
|
|
133
|
+
const ancestorCap = inheritedCapability(targetDef, resolveDef);
|
|
134
|
+
if (ownCapResolved && ancestorCap && ownCapResolved !== ancestorCap) {
|
|
135
|
+
diagnostics.push({
|
|
136
|
+
severity: DiagnosticSeverity.Error,
|
|
137
|
+
code: "EXTENDS_CAPABILITY_MISMATCH",
|
|
138
|
+
source: SOURCE,
|
|
139
|
+
message:
|
|
140
|
+
`${label}: declares 'capability: ${ownCap}' but extends '${extendsValue}' whose inherited capability is '${ancestorCap}'. ` +
|
|
141
|
+
`Capability is inherited and immutable — omit 'capability' or restate it identically.`,
|
|
142
|
+
data: { resource, filePath, path: "capability" },
|
|
143
|
+
});
|
|
144
|
+
}
|
|
132
145
|
}
|
|
133
146
|
}
|
|
134
147
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { ResourceManifest } from "@telorun/sdk";
|
|
1
|
+
import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
2
2
|
import type { AliasResolver } from "./alias-resolver.js";
|
|
3
3
|
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
4
|
+
import { controllerBearingAncestor, type DefResolver } from "./extends-resolution.js";
|
|
4
5
|
import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
|
|
5
6
|
|
|
6
7
|
const SOURCE = "telo-analyzer";
|
|
@@ -234,7 +235,16 @@ export function validateProviderCoherence(
|
|
|
234
235
|
}
|
|
235
236
|
}
|
|
236
237
|
|
|
237
|
-
|
|
238
|
+
// A definition that inherits a controller by delegation (concrete `extends`,
|
|
239
|
+
// no own controller/template) satisfies the implementation requirement
|
|
240
|
+
// through its parent — `base:` supplies the parent's config.
|
|
241
|
+
const resolveDef: DefResolver = (k) =>
|
|
242
|
+
registry.resolve(aliases.resolveKind(k) ?? k) ?? registry.resolve(k);
|
|
243
|
+
const inheritsController =
|
|
244
|
+
typeof md.extends === "string" &&
|
|
245
|
+
controllerBearingAncestor(m as ResourceDefinition, resolveDef) !== undefined;
|
|
246
|
+
|
|
247
|
+
if (capability === "Telo.Provider" && !hasControllers && !hasProvide && !inheritsController) {
|
|
238
248
|
diagnostics.push({
|
|
239
249
|
severity: DiagnosticSeverity.Error,
|
|
240
250
|
code: "PROVIDER_MISSING_IMPLEMENTATION",
|
|
@@ -29,20 +29,24 @@ function checkKind(
|
|
|
29
29
|
if (!targetKind) return [];
|
|
30
30
|
const targetDef = registry.resolve(targetKind);
|
|
31
31
|
if (!targetDef) return [];
|
|
32
|
+
// Liskov substitutability: a value satisfies the slot when it transitively
|
|
33
|
+
// extends the target kind, or — for a CONCRETE target — IS that kind.
|
|
34
|
+
// `getByExtends` is the same transitive subtype index for abstract and
|
|
35
|
+
// concrete targets alike; an abstract is satisfied only by an implementer,
|
|
36
|
+
// never by the abstract kind itself (which is non-instantiable).
|
|
37
|
+
if (targetDef.kind !== "Telo.Abstract" && resolved === targetKind) return [];
|
|
38
|
+
const subtypes = registry.getByExtends(targetKind);
|
|
39
|
+
const subtypeKinds = new Set(subtypes.map((d) => `${d.metadata.module}.${d.metadata.name}`));
|
|
40
|
+
if (subtypeKinds.has(resolved)) return [];
|
|
32
41
|
if (targetDef.kind === "Telo.Abstract") {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const implementingKinds = new Set(
|
|
36
|
-
implementing.map((d) => `${d.metadata.module}.${d.metadata.name}`),
|
|
37
|
-
);
|
|
38
|
-
if (implementingKinds.has(resolved)) return [];
|
|
39
|
-
const options = [...implementingKinds].join(", ");
|
|
42
|
+
if (subtypes.length === 0) return []; // partial context — no implementations loaded yet
|
|
43
|
+
const options = [...subtypeKinds].join(", ");
|
|
40
44
|
errors.push(
|
|
41
45
|
`'${kind}' does not implement '${targetKind}' (known implementations: ${options})`,
|
|
42
46
|
);
|
|
43
47
|
} else {
|
|
44
|
-
|
|
45
|
-
errors.push(`'${kind}' (resolved: '${resolved}') does not match required '${targetKind}'`);
|
|
48
|
+
const options = subtypeKinds.size > 0 ? ` or a subtype (${[...subtypeKinds].join(", ")})` : "";
|
|
49
|
+
errors.push(`'${kind}' (resolved: '${resolved}') does not match required '${targetKind}'${options}`);
|
|
46
50
|
}
|
|
47
51
|
}
|
|
48
52
|
return errors;
|