@telorun/analyzer 0.32.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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 +284 -80
- 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 +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/sources/integrity.d.ts +7 -0
- package/dist/sources/integrity.d.ts.map +1 -1
- package/dist/sources/integrity.js +12 -2
- package/dist/sources/module-ref.d.ts +6 -2
- package/dist/sources/module-ref.d.ts.map +1 -1
- package/dist/sources/module-ref.js +7 -6
- 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 +130 -0
- package/dist/validate-extends.d.ts.map +1 -1
- package/dist/validate-extends.js +20 -9
- package/dist/validate-kind-descriptions.d.ts +20 -0
- package/dist/validate-kind-descriptions.d.ts.map +1 -0
- package/dist/validate-kind-descriptions.js +65 -0
- 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 +3 -3
- package/src/analysis-registry.ts +9 -8
- package/src/analyzer.ts +333 -91
- package/src/builtins.ts +15 -0
- package/src/definition-registry.ts +18 -3
- package/src/extends-resolution.ts +124 -0
- package/src/index.ts +13 -0
- package/src/sources/integrity.ts +13 -2
- package/src/sources/module-ref.ts +7 -6
- package/src/validate-base-mapping.ts +154 -0
- package/src/validate-extends.ts +24 -11
- package/src/validate-kind-descriptions.ts +65 -0
- package/src/validate-provider-coherence.ts +12 -2
- package/src/validate-references.ts +13 -9
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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
2
2
|
import { canonicalTypeSchemaId } from "@telorun/sdk";
|
|
3
3
|
import type { Environment } from "@marcbachmann/cel-js";
|
|
4
|
-
import { defaultRegistry, isTaggedSentinel } from "@telorun/templating";
|
|
4
|
+
import { defaultRegistry, isRefSentinel, isTaggedSentinel } from "@telorun/templating";
|
|
5
5
|
import { AliasResolver, scopeResolverForModule } from "./alias-resolver.js";
|
|
6
6
|
import { AnalysisRegistry } from "./analysis-registry.js";
|
|
7
7
|
import {
|
|
@@ -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,7 +40,9 @@ 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";
|
|
45
|
+
import { validateKindDescriptions } from "./validate-kind-descriptions.js";
|
|
43
46
|
import { validateProviderCoherence } from "./validate-provider-coherence.js";
|
|
44
47
|
import { validateReferences } from "./validate-references.js";
|
|
45
48
|
import { validateReferenceForms } from "./validate-reference-forms.js";
|
|
@@ -122,8 +125,20 @@ const SOURCE = "telo-analyzer";
|
|
|
122
125
|
* property the user declared in `schema:` plus synthetic `name` / `kind` and
|
|
123
126
|
* the metadata sub-object (kept open since metadata legitimately carries
|
|
124
127
|
* arbitrary user-added fields). */
|
|
125
|
-
function buildSelfSchema(
|
|
126
|
-
|
|
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>;
|
|
127
142
|
const userProps = (userSchema.properties ?? {}) as Record<string, any>;
|
|
128
143
|
const userRequired = Array.isArray(userSchema.required) ? userSchema.required : [];
|
|
129
144
|
return {
|
|
@@ -192,7 +207,7 @@ function manifestRootForResolver(
|
|
|
192
207
|
const inputs = lookupTemplateInputsSchema(m, defs, aliases, allManifests);
|
|
193
208
|
return {
|
|
194
209
|
...m,
|
|
195
|
-
schema: buildSelfSchema(m),
|
|
210
|
+
schema: buildSelfSchema(m, defs, aliases),
|
|
196
211
|
...(inputs ? { inputType: inputs } : {}),
|
|
197
212
|
};
|
|
198
213
|
}
|
|
@@ -236,6 +251,73 @@ function gatherPropertySchemas(schema: Record<string, any>): Array<[string, Reco
|
|
|
236
251
|
return out;
|
|
237
252
|
}
|
|
238
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Generic, role-driven walk over an `x-telo-step-context` step array. Calls
|
|
256
|
+
* `visit(step, stepPath)` for every step — top-level and nested through the
|
|
257
|
+
* `x-telo-topology-role` forms (`branch`, `branch-list`, `case-map`). This is
|
|
258
|
+
* the single definition of how steps nest, shared by `buildStepContextSchema`
|
|
259
|
+
* (which types `steps.<name>.result`) and `validateStepInvokeReferences` (which
|
|
260
|
+
* checks invoke refs), so the topology contract lives in one place — adding a
|
|
261
|
+
* role or nesting form updates both consumers at once. No resource kind is
|
|
262
|
+
* hardcoded; recursion is driven entirely by the schema annotations.
|
|
263
|
+
*/
|
|
264
|
+
function walkStepArray(
|
|
265
|
+
steps: unknown[],
|
|
266
|
+
stepItemSchema: Record<string, any> | undefined,
|
|
267
|
+
rootSchema: Record<string, any>,
|
|
268
|
+
basePath: string,
|
|
269
|
+
visit: (step: Record<string, any>, stepPath: string) => void,
|
|
270
|
+
): void {
|
|
271
|
+
const dispatchRole = (
|
|
272
|
+
data: unknown,
|
|
273
|
+
role: string,
|
|
274
|
+
itemsSchema: Record<string, any> | undefined,
|
|
275
|
+
path: string,
|
|
276
|
+
): void => {
|
|
277
|
+
if (role === "branch" && Array.isArray(data)) {
|
|
278
|
+
walkStepArray(data, stepItemSchema, rootSchema, path, visit);
|
|
279
|
+
} else if (role === "case-map" && data && typeof data === "object" && !Array.isArray(data)) {
|
|
280
|
+
for (const [caseKey, arr] of Object.entries(data as Record<string, unknown>)) {
|
|
281
|
+
if (Array.isArray(arr)) walkStepArray(arr, stepItemSchema, rootSchema, `${path}.${caseKey}`, visit);
|
|
282
|
+
}
|
|
283
|
+
} else if (role === "branch-list" && Array.isArray(data)) {
|
|
284
|
+
const entrySchema = resolveLocalRef(itemsSchema, rootSchema);
|
|
285
|
+
if (!entrySchema) return;
|
|
286
|
+
data.forEach((entry, i) => {
|
|
287
|
+
if (!entry || typeof entry !== "object") return;
|
|
288
|
+
for (const [subKey, subSchema] of gatherPropertySchemas(entrySchema)) {
|
|
289
|
+
const subRole = subSchema["x-telo-topology-role"];
|
|
290
|
+
if (typeof subRole !== "string") continue;
|
|
291
|
+
dispatchRole(
|
|
292
|
+
(entry as Record<string, any>)[subKey],
|
|
293
|
+
subRole,
|
|
294
|
+
subSchema.items as Record<string, any> | undefined,
|
|
295
|
+
`${path}[${i}].${subKey}`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
steps.forEach((step, i) => {
|
|
303
|
+
if (!step || typeof step !== "object") return;
|
|
304
|
+
const s = step as Record<string, any>;
|
|
305
|
+
const stepPath = `${basePath}[${i}]`;
|
|
306
|
+
visit(s, stepPath);
|
|
307
|
+
if (!stepItemSchema) return;
|
|
308
|
+
for (const [propKey, propSchema] of gatherPropertySchemas(stepItemSchema)) {
|
|
309
|
+
const role = propSchema["x-telo-topology-role"];
|
|
310
|
+
if (typeof role !== "string") continue;
|
|
311
|
+
dispatchRole(
|
|
312
|
+
s[propKey],
|
|
313
|
+
role,
|
|
314
|
+
propSchema.items as Record<string, any> | undefined,
|
|
315
|
+
`${stepPath}.${propKey}`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
239
321
|
/**
|
|
240
322
|
* Build a `steps` context schema from `x-telo-step-context` annotation.
|
|
241
323
|
* Walks each step in the manifest array, resolves the invoked resource's outputType,
|
|
@@ -290,90 +372,47 @@ function buildStepContextSchema(
|
|
|
290
372
|
|
|
291
373
|
const stepProperties: Record<string, any> = {};
|
|
292
374
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
dispatchRole(
|
|
313
|
-
(entry as Record<string, any>)[subKey],
|
|
314
|
-
subRole,
|
|
315
|
-
subSchema.items as Record<string, any> | undefined,
|
|
316
|
-
);
|
|
317
|
-
}
|
|
375
|
+
walkStepArray(steps, stepItemSchema, defSchema, fieldName, (s) => {
|
|
376
|
+
const name = s.name;
|
|
377
|
+
const invoke = s[invokeField] as Record<string, any> | undefined;
|
|
378
|
+
// Only invoke steps register a `steps.<name>.result` entry — control-flow
|
|
379
|
+
// wrappers (try/if/while/switch/throw) don't produce a result and must
|
|
380
|
+
// not shadow real entries with a permissive `additionalProperties: true`,
|
|
381
|
+
// or unknown step references slip through chain validation.
|
|
382
|
+
if (typeof name !== "string" || !invoke || typeof invoke !== "object") return;
|
|
383
|
+
let outputSchema: Record<string, any> | undefined;
|
|
384
|
+
const invokedKind = invoke.kind as string | undefined;
|
|
385
|
+
const invokedName = invoke.name as string | undefined;
|
|
386
|
+
if (invokedName) {
|
|
387
|
+
const invokedManifest = allManifests.find(
|
|
388
|
+
(m) =>
|
|
389
|
+
(m.metadata as any)?.name === invokedName &&
|
|
390
|
+
(!invokedKind || m.kind === invokedKind),
|
|
391
|
+
) as Record<string, any> | undefined;
|
|
392
|
+
if (invokedManifest) {
|
|
393
|
+
outputSchema = resolveTypeFieldToSchema(invokedManifest[outputTypeField], allManifests);
|
|
318
394
|
}
|
|
395
|
+
} else {
|
|
396
|
+
outputSchema = resolveTypeFieldToSchema(invoke[outputTypeField], allManifests);
|
|
319
397
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
// not shadow real entries with a permissive `additionalProperties: true`,
|
|
331
|
-
// or unknown step references slip through chain validation.
|
|
332
|
-
if (typeof name === "string" && invoke && typeof invoke === "object") {
|
|
333
|
-
let outputSchema: Record<string, any> | undefined;
|
|
334
|
-
const invokedKind = invoke.kind as string | undefined;
|
|
335
|
-
const invokedName = invoke.name as string | undefined;
|
|
336
|
-
if (invokedName) {
|
|
337
|
-
const invokedManifest = allManifests.find(
|
|
338
|
-
(m) =>
|
|
339
|
-
(m.metadata as any)?.name === invokedName &&
|
|
340
|
-
(!invokedKind || m.kind === invokedKind),
|
|
341
|
-
) as Record<string, any> | undefined;
|
|
342
|
-
if (invokedManifest) {
|
|
343
|
-
outputSchema = resolveTypeFieldToSchema(invokedManifest[outputTypeField], allManifests);
|
|
344
|
-
}
|
|
345
|
-
} else {
|
|
346
|
-
outputSchema = resolveTypeFieldToSchema(invoke[outputTypeField], allManifests);
|
|
347
|
-
}
|
|
348
|
-
// Fallback: pull outputType from the kind's Telo.Definition. The
|
|
349
|
-
// resource manifest typically doesn't carry outputType; the def does.
|
|
350
|
-
if (!outputSchema && invokedKind) {
|
|
351
|
-
outputSchema = lookupDefinitionTypeField(
|
|
352
|
-
invokedKind,
|
|
353
|
-
outputTypeField,
|
|
354
|
-
defs,
|
|
355
|
-
aliases,
|
|
356
|
-
allManifests,
|
|
357
|
-
);
|
|
358
|
-
}
|
|
359
|
-
stepProperties[name] = {
|
|
360
|
-
type: "object",
|
|
361
|
-
properties: {
|
|
362
|
-
result: outputSchema ?? { type: "object", additionalProperties: true },
|
|
363
|
-
},
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
|
-
if (stepItemSchema) {
|
|
367
|
-
for (const [propKey, propSchema] of gatherPropertySchemas(stepItemSchema)) {
|
|
368
|
-
const role = propSchema["x-telo-topology-role"];
|
|
369
|
-
if (typeof role !== "string") continue;
|
|
370
|
-
dispatchRole(s[propKey], role, propSchema.items as Record<string, any> | undefined);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
398
|
+
// Fallback: pull outputType from the kind's Telo.Definition. The
|
|
399
|
+
// resource manifest typically doesn't carry outputType; the def does.
|
|
400
|
+
if (!outputSchema && invokedKind) {
|
|
401
|
+
outputSchema = lookupDefinitionTypeField(
|
|
402
|
+
invokedKind,
|
|
403
|
+
outputTypeField,
|
|
404
|
+
defs,
|
|
405
|
+
aliases,
|
|
406
|
+
allManifests,
|
|
407
|
+
);
|
|
373
408
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
409
|
+
stepProperties[name] = {
|
|
410
|
+
type: "object",
|
|
411
|
+
properties: {
|
|
412
|
+
result: outputSchema ?? { type: "object", additionalProperties: true },
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
});
|
|
377
416
|
|
|
378
417
|
if (Object.keys(stepProperties).length > 0) {
|
|
379
418
|
return {
|
|
@@ -386,6 +425,192 @@ function buildStepContextSchema(
|
|
|
386
425
|
return undefined;
|
|
387
426
|
}
|
|
388
427
|
|
|
428
|
+
/**
|
|
429
|
+
* Capabilities whose instances structurally expose no `invoke`/`run` method, so
|
|
430
|
+
* a step `invoke` of one always fails at runtime with ERR_RESOURCE_NOT_INVOKABLE
|
|
431
|
+
* (kernel dispatch checks method presence, not capability — evaluation-context.ts).
|
|
432
|
+
* `Telo.Service` is intentionally absent: some services are invocable (a function
|
|
433
|
+
* handler dispatched directly, e.g. `Lambda.Function`), so it can't be rejected
|
|
434
|
+
* statically without false positives. This is the sound subset of the runtime rule.
|
|
435
|
+
*/
|
|
436
|
+
const NON_INVOKABLE_CAPABILITIES = new Set([
|
|
437
|
+
"Telo.Provider",
|
|
438
|
+
"Telo.Mount",
|
|
439
|
+
"Telo.Type",
|
|
440
|
+
"Telo.Template",
|
|
441
|
+
]);
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Validate `x-telo-step-context` step `invoke` references (e.g. `Run.Sequence`
|
|
445
|
+
* steps).
|
|
446
|
+
*
|
|
447
|
+
* The reference field map deliberately does NOT descend into step `invoke`
|
|
448
|
+
* slots — they sit behind a local `$ref` to the shared step definition, and
|
|
449
|
+
* turning the descent on would make Phase 5 inject live instances there,
|
|
450
|
+
* breaking the invoke dispatch path (see `reference-field-map.ts`). A
|
|
451
|
+
* consequence is that `validateReferences` never sees these slots, so a bad
|
|
452
|
+
* step invoke passes `telo check` and only fails at runtime. This pass covers
|
|
453
|
+
* exactly those slots, in two dimensions:
|
|
454
|
+
* - Existence: an `invoke: !ref <name>` that names a missing instance — or a
|
|
455
|
+
* *kind* instead of an exported instance (`!ref Stream.Of`) — is a still-a-
|
|
456
|
+
* sentinel after Phase 2.5 resolution → `UNRESOLVED_REFERENCE` (runtime
|
|
457
|
+
* `ERR_RESOURCE_NOT_FOUND`).
|
|
458
|
+
* - Invokability: a resolved instance whose capability structurally has no
|
|
459
|
+
* invoke/run method (`NON_INVOKABLE_CAPABILITIES`) → `REFERENCE_KIND_MISMATCH`
|
|
460
|
+
* (runtime `ERR_RESOURCE_NOT_INVOKABLE`).
|
|
461
|
+
*
|
|
462
|
+
* Generic and topology-driven — it walks steps via the same `x-telo-step-context`
|
|
463
|
+
* / `x-telo-topology-role` annotations `buildStepContextSchema` uses (through the
|
|
464
|
+
* shared `walkStepArray`), so nested branches (then/else/do/catch/cases) are
|
|
465
|
+
* covered and no `Run.Sequence` field name is hardcoded. The cross-module
|
|
466
|
+
* partial-analysis guard mirrors `validateReferences`, so a reference into an
|
|
467
|
+
* unloaded import is skipped rather than false-flagged.
|
|
468
|
+
*/
|
|
469
|
+
function validateStepInvokeReferences(
|
|
470
|
+
allManifests: Record<string, any>[],
|
|
471
|
+
defs: DefinitionRegistry,
|
|
472
|
+
aliases: AliasResolver,
|
|
473
|
+
): AnalysisDiagnostic[] {
|
|
474
|
+
const diagnostics: AnalysisDiagnostic[] = [];
|
|
475
|
+
|
|
476
|
+
// Local instance names + loaded-module set — same construction as
|
|
477
|
+
// validateReferences, so the cross-module guard behaves identically.
|
|
478
|
+
const localNames = new Set<string>();
|
|
479
|
+
const loadedModules = new Set<string>();
|
|
480
|
+
|
|
481
|
+
// Also collect names of resources nested inside a manifest tree — notably
|
|
482
|
+
// `with:`-scoped resources (an `x-telo-scope` region the field map does not
|
|
483
|
+
// extract to a top-level manifest). A step can invoke one by bare name, so
|
|
484
|
+
// omitting them would false-flag a valid `!ref`. Conservative: any nested
|
|
485
|
+
// object carrying both a `kind` and a `metadata.name` is a resource
|
|
486
|
+
// definition; scope visibility is left to the runtime.
|
|
487
|
+
const collectNestedNames = (value: unknown): void => {
|
|
488
|
+
if (!value || typeof value !== "object" || isTaggedSentinel(value)) return;
|
|
489
|
+
if (Array.isArray(value)) {
|
|
490
|
+
for (const item of value) collectNestedNames(item);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const obj = value as Record<string, unknown>;
|
|
494
|
+
const name = (obj.metadata as { name?: unknown } | undefined)?.name;
|
|
495
|
+
if (typeof obj.kind === "string" && typeof name === "string") localNames.add(name);
|
|
496
|
+
for (const v of Object.values(obj)) collectNestedNames(v);
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
for (const r of allManifests) {
|
|
500
|
+
if (r.kind === "Telo.Import") {
|
|
501
|
+
const m = (r.metadata as { resolvedModuleName?: unknown } | undefined)?.resolvedModuleName;
|
|
502
|
+
if (typeof m === "string") loadedModules.add(m);
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
const meta = r.metadata as { name?: unknown; module?: unknown; forwardedExport?: unknown };
|
|
506
|
+
if (typeof meta?.name !== "string" || REF_VALIDATION_SKIP_KINDS.has(r.kind)) continue;
|
|
507
|
+
if (meta.forwardedExport === true) {
|
|
508
|
+
if (typeof meta.module === "string") loadedModules.add(meta.module);
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
localNames.add(meta.name);
|
|
512
|
+
collectNestedNames(r);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const validateInvoke = (
|
|
516
|
+
value: unknown,
|
|
517
|
+
resource: { kind: string; name: string },
|
|
518
|
+
filePath: string | undefined,
|
|
519
|
+
path: string,
|
|
520
|
+
): void => {
|
|
521
|
+
if (isRefSentinel(value)) {
|
|
522
|
+
// An unresolved `!ref` is a miss: a real instance would have resolved to
|
|
523
|
+
// `{kind, name}` in Phase 2.5.
|
|
524
|
+
const refName = value.source;
|
|
525
|
+
const dot = refName.indexOf(".");
|
|
526
|
+
const aliasPrefix = dot > 0 ? refName.slice(0, dot) : undefined;
|
|
527
|
+
|
|
528
|
+
if (aliasPrefix && aliasPrefix !== "Self" && aliases.hasAlias(aliasPrefix)) {
|
|
529
|
+
const module = aliases.moduleForAlias(aliasPrefix);
|
|
530
|
+
// Partial single-file analysis (import not loaded) — skip to avoid a false miss.
|
|
531
|
+
if (module && !loadedModules.has(module)) return;
|
|
532
|
+
diagnostics.push({
|
|
533
|
+
severity: DiagnosticSeverity.Error,
|
|
534
|
+
code: "UNRESOLVED_REFERENCE",
|
|
535
|
+
source: SOURCE,
|
|
536
|
+
message: `${resource.kind}/${resource.name}: step invoke at '${path}' → '${refName}' is not an exported instance of module '${module ?? aliasPrefix}' (reference a declared instance, not a kind)`,
|
|
537
|
+
data: { resource, filePath, path },
|
|
538
|
+
});
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const localName = aliasPrefix === "Self" ? refName.slice(dot + 1) : refName;
|
|
543
|
+
if (localNames.has(localName)) return;
|
|
544
|
+
diagnostics.push({
|
|
545
|
+
severity: DiagnosticSeverity.Error,
|
|
546
|
+
code: "UNRESOLVED_REFERENCE",
|
|
547
|
+
source: SOURCE,
|
|
548
|
+
message: `${resource.kind}/${resource.name}: step invoke at '${path}' → resource '${localName}' not found`,
|
|
549
|
+
data: { resource, filePath, path },
|
|
550
|
+
});
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Resolved `{kind, name}` (or an inline `{kind, …}` definition) — the
|
|
555
|
+
// instance exists. Mirror the kernel's ERR_RESOURCE_NOT_INVOKABLE, which
|
|
556
|
+
// fires when the instance has neither an `invoke` nor a `run` method
|
|
557
|
+
// (evaluation-context.ts). That is a per-instance property, not a pure
|
|
558
|
+
// capability, so only capabilities that STRUCTURALLY expose no such method
|
|
559
|
+
// are rejected statically — Service is intentionally excluded, since some
|
|
560
|
+
// services are invocable (e.g. a function handler dispatched directly).
|
|
561
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
|
562
|
+
const kind = (value as Record<string, unknown>).kind;
|
|
563
|
+
if (typeof kind !== "string") return;
|
|
564
|
+
const capability = defs.resolve(aliases.resolveKind(kind) ?? kind)?.capability;
|
|
565
|
+
if (typeof capability === "string" && NON_INVOKABLE_CAPABILITIES.has(capability)) {
|
|
566
|
+
diagnostics.push({
|
|
567
|
+
severity: DiagnosticSeverity.Error,
|
|
568
|
+
code: "REFERENCE_KIND_MISMATCH",
|
|
569
|
+
source: SOURCE,
|
|
570
|
+
message: `${resource.kind}/${resource.name}: step invoke at '${path}' → '${kind}' is a ${capability} and cannot be invoked in a step — it has no invoke or run method (runtime ERR_RESOURCE_NOT_INVOKABLE)`,
|
|
571
|
+
data: { resource, filePath, path },
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
for (const m of allManifests) {
|
|
577
|
+
const meta = m.metadata as { name?: unknown; source?: unknown; forwardedExport?: unknown };
|
|
578
|
+
if (
|
|
579
|
+
typeof meta?.name !== "string" ||
|
|
580
|
+
REF_VALIDATION_SKIP_KINDS.has(m.kind) ||
|
|
581
|
+
meta.forwardedExport === true
|
|
582
|
+
)
|
|
583
|
+
continue;
|
|
584
|
+
const def = defs.resolve(aliases.resolveKind(m.kind) ?? m.kind);
|
|
585
|
+
const defSchema = def?.schema as Record<string, any> | undefined;
|
|
586
|
+
if (!defSchema?.properties) continue;
|
|
587
|
+
const resource = { kind: m.kind, name: meta.name };
|
|
588
|
+
const filePath = typeof meta.source === "string" ? meta.source : undefined;
|
|
589
|
+
|
|
590
|
+
for (const [fieldName, fieldSchema] of Object.entries(
|
|
591
|
+
defSchema.properties as Record<string, any>,
|
|
592
|
+
)) {
|
|
593
|
+
const stepCtx = fieldSchema["x-telo-step-context"] as Record<string, string> | undefined;
|
|
594
|
+
const invokeField = stepCtx?.invoke;
|
|
595
|
+
if (!invokeField) continue;
|
|
596
|
+
const steps = m[fieldName];
|
|
597
|
+
if (!Array.isArray(steps)) continue;
|
|
598
|
+
const stepItemSchema = resolveLocalRef(
|
|
599
|
+
fieldSchema.items as Record<string, any> | undefined,
|
|
600
|
+
defSchema,
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
walkStepArray(steps, stepItemSchema, defSchema, fieldName, (s, stepPath) => {
|
|
604
|
+
const invoke = s[invokeField];
|
|
605
|
+
if (invoke === undefined || invoke === null) return;
|
|
606
|
+
validateInvoke(invoke, resource, filePath, `${stepPath}.${invokeField}`);
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return diagnostics;
|
|
612
|
+
}
|
|
613
|
+
|
|
389
614
|
/**
|
|
390
615
|
* Collect every field annotated with `x-telo-error-context` anywhere in a
|
|
391
616
|
* definition schema (resolving local `$ref`s into `$defs`, cycle-safe), mapping
|
|
@@ -1019,21 +1244,28 @@ export class StaticAnalyzer {
|
|
|
1019
1244
|
continue;
|
|
1020
1245
|
}
|
|
1021
1246
|
|
|
1022
|
-
// 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.
|
|
1023
1252
|
// `kind` and `metadata` are implicit on every resource — inject them so module
|
|
1024
1253
|
// authors don't have to repeat them when using additionalProperties: false.
|
|
1025
|
-
|
|
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) {
|
|
1026
1258
|
const schema =
|
|
1027
|
-
|
|
1259
|
+
authorSchema.additionalProperties === false
|
|
1028
1260
|
? {
|
|
1029
|
-
...
|
|
1261
|
+
...authorSchema,
|
|
1030
1262
|
properties: {
|
|
1031
1263
|
kind: { type: "string" },
|
|
1032
1264
|
metadata: { type: "object" },
|
|
1033
|
-
...
|
|
1265
|
+
...authorSchema.properties,
|
|
1034
1266
|
},
|
|
1035
1267
|
}
|
|
1036
|
-
:
|
|
1268
|
+
: authorSchema;
|
|
1037
1269
|
// Phase 1: CEL type checking — walk data+schema together, check env.check() return types.
|
|
1038
1270
|
// A Telo.Import's variables/secrets are a config-only contract evaluated against the
|
|
1039
1271
|
// IMPORTING module's scope, so type them from the owning module doc (matched by
|
|
@@ -1409,12 +1641,22 @@ export class StaticAnalyzer {
|
|
|
1409
1641
|
...validateReferences(allManifests, { aliases, definitions: defs, aliasesByModule }),
|
|
1410
1642
|
);
|
|
1411
1643
|
|
|
1644
|
+
// Validate step `invoke` references — the slots the reference field map
|
|
1645
|
+
// deliberately skips (behind the step `$ref`), so a missing instance or a
|
|
1646
|
+
// kind-instead-of-instance ref there is caught statically, not at runtime.
|
|
1647
|
+
diagnostics.push(...validateStepInvokeReferences(allManifests, defs, aliases));
|
|
1648
|
+
|
|
1412
1649
|
// Validate `extends` fields and flag legacy `capability: <UserAbstract>` overload.
|
|
1413
1650
|
diagnostics.push(...validateExtends(allManifests, defs, aliases));
|
|
1414
1651
|
|
|
1652
|
+
diagnostics.push(...validateBaseMapping(allManifests, defs, aliases));
|
|
1653
|
+
|
|
1415
1654
|
// Validate provider coherence rules for `provide:` template-target definitions.
|
|
1416
1655
|
diagnostics.push(...validateProviderCoherence(allManifests, defs, aliases));
|
|
1417
1656
|
|
|
1657
|
+
// Warn about exported kinds lacking a metadata.description (semantic-search input).
|
|
1658
|
+
diagnostics.push(...validateKindDescriptions(allManifests));
|
|
1659
|
+
|
|
1418
1660
|
// Validate throws: declarations and catches: coverage (rules 1, 2, 4, 7)
|
|
1419
1661
|
diagnostics.push(
|
|
1420
1662
|
...validateThrowsCoverage(allManifests, defs, aliases, this.celEnv, aliasesByModule, rootModules),
|
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. */
|