@telorun/analyzer 0.72.0 → 0.73.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +50 -13
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +71 -97
- package/dist/cel-scope-query.d.ts.map +1 -1
- package/dist/cel-scope-query.js +32 -11
- package/dist/cel-scope.d.ts.map +1 -1
- package/dist/cel-scope.js +1 -0
- package/dist/eval-paths.d.ts +36 -5
- package/dist/eval-paths.d.ts.map +1 -1
- package/dist/eval-paths.js +47 -6
- package/dist/extends-resolution.d.ts +12 -0
- package/dist/extends-resolution.d.ts.map +1 -1
- package/dist/extends-resolution.js +23 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/nearest-name.d.ts +22 -0
- package/dist/nearest-name.d.ts.map +1 -0
- package/dist/nearest-name.js +43 -0
- package/dist/release/destinations.d.ts +59 -0
- package/dist/release/destinations.d.ts.map +1 -0
- package/dist/release/destinations.js +75 -0
- package/dist/release/index.d.ts +6 -2
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +3 -1
- package/dist/release/ledger.d.ts +20 -11
- package/dist/release/ledger.d.ts.map +1 -1
- package/dist/release/ledger.js +43 -14
- package/dist/release/release-plan.d.ts +3 -2
- package/dist/release/release-plan.d.ts.map +1 -1
- package/dist/release/release-plan.js +12 -19
- package/dist/release/workspace-config.d.ts +114 -34
- package/dist/release/workspace-config.d.ts.map +1 -1
- package/dist/release/workspace-config.js +322 -38
- package/dist/release/workspace-schema.d.ts +41 -0
- package/dist/release/workspace-schema.d.ts.map +1 -0
- package/dist/release/workspace-schema.js +77 -0
- package/dist/schema-error-report.d.ts +7 -0
- package/dist/schema-error-report.d.ts.map +1 -1
- package/dist/schema-error-report.js +9 -4
- package/dist/telo-version.d.ts +1 -1
- package/dist/telo-version.js +1 -1
- package/dist/template-body.d.ts +10 -0
- package/dist/template-body.d.ts.map +1 -1
- package/dist/template-body.js +25 -0
- package/dist/validate-cel-context.d.ts +23 -8
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +48 -16
- package/dist/validate-exports.d.ts +28 -0
- package/dist/validate-exports.d.ts.map +1 -0
- package/dist/validate-exports.js +146 -0
- package/dist/validate-extends.d.ts.map +1 -1
- package/dist/validate-extends.js +37 -1
- package/dist/validate-provider-coherence.d.ts +4 -8
- package/dist/validate-provider-coherence.d.ts.map +1 -1
- package/dist/validate-provider-coherence.js +12 -123
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +141 -60
- package/dist/validate-template-body.d.ts +36 -0
- package/dist/validate-template-body.d.ts.map +1 -0
- package/dist/validate-template-body.js +246 -0
- package/package.json +3 -2
- package/src/analyzer.ts +64 -11
- package/src/builtins.ts +85 -97
- package/src/cel-scope-query.ts +28 -9
- package/src/cel-scope.ts +1 -0
- package/src/eval-paths.ts +53 -6
- package/src/extends-resolution.ts +24 -0
- package/src/index.ts +1 -0
- package/src/nearest-name.ts +47 -0
- package/src/release/destinations.ts +105 -0
- package/src/release/index.ts +33 -2
- package/src/release/ledger.ts +51 -21
- package/src/release/release-plan.ts +16 -26
- package/src/release/workspace-config.ts +483 -47
- package/src/release/workspace-schema.ts +99 -0
- package/src/schema-error-report.ts +16 -4
- package/src/telo-version.ts +1 -1
- package/src/template-body.ts +25 -0
- package/src/validate-cel-context.ts +57 -16
- package/src/validate-exports.ts +185 -0
- package/src/validate-extends.ts +43 -0
- package/src/validate-provider-coherence.ts +17 -127
- package/src/validate-references.ts +141 -60
- package/src/validate-template-body.ts +274 -0
- package/dist/validate-template-dispatch.d.ts +0 -27
- package/dist/validate-template-dispatch.d.ts.map +0 -1
- package/dist/validate-template-dispatch.js +0 -95
- package/src/validate-template-dispatch.ts +0 -99
|
@@ -44,6 +44,13 @@ export interface SchemaIssue {
|
|
|
44
44
|
message: string;
|
|
45
45
|
/** Dotted path to the field (e.g. "config.handler"). Empty string means root. */
|
|
46
46
|
path: string;
|
|
47
|
+
/** The AJV keyword that produced it, so a consumer can key on WHAT failed
|
|
48
|
+
* rather than on how the sentence reads. Prose is the renderer's to change;
|
|
49
|
+
* a caller matching on it breaks silently when it does, and mis-fires on any
|
|
50
|
+
* other issue whose text happens to quote the same name. */
|
|
51
|
+
keyword?: string;
|
|
52
|
+
/** For `required`, the property that is missing. */
|
|
53
|
+
missingProperty?: string;
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
const UNION_KEYWORDS = new Set(["anyOf", "oneOf"]);
|
|
@@ -447,10 +454,15 @@ export function ajvErrorToPath(err: AjvErrorLike): string {
|
|
|
447
454
|
|
|
448
455
|
/** Reduced, path-anchored issues — what a diagnostic list is built from. */
|
|
449
456
|
export function schemaIssues(errors: AjvErrorLike[] | null | undefined): SchemaIssue[] {
|
|
450
|
-
return reduceSchemaErrors(errors).map((err) =>
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
457
|
+
return reduceSchemaErrors(errors).map((err) => {
|
|
458
|
+
const missing = (err.params ?? {}).missingProperty;
|
|
459
|
+
return {
|
|
460
|
+
message: formatSingleError(err),
|
|
461
|
+
path: ajvErrorToPath(err),
|
|
462
|
+
...(err.keyword ? { keyword: err.keyword } : {}),
|
|
463
|
+
...(typeof missing === "string" ? { missingProperty: missing } : {}),
|
|
464
|
+
};
|
|
465
|
+
});
|
|
454
466
|
}
|
|
455
467
|
|
|
456
468
|
/** Reduced, rendered as one sentence — what a thrown runtime error carries. */
|
package/src/telo-version.ts
CHANGED
package/src/template-body.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { isRefSentinel } from "@telorun/templating";
|
|
2
3
|
import type { AliasResolver } from "./alias-resolver.js";
|
|
3
4
|
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
4
5
|
import type { ModuleScopes } from "./alias-resolver.js";
|
|
@@ -68,6 +69,30 @@ export function templateBodies(
|
|
|
68
69
|
return out;
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
/** What a definition's dispatch slot names: the `resources:` entry a `!ref` at
|
|
73
|
+
* `slot` (`invoke` / `provide` / `run` / `mount`) resolves to, with its kind.
|
|
74
|
+
* The entry is matched by its LITERAL `metadata.name`; a slot holding anything
|
|
75
|
+
* but a `!ref`, or naming no entry, resolves to nothing — `validate-template-body`
|
|
76
|
+
* is what reports either. `Self.<entry>` is the explicit self-qualifier. */
|
|
77
|
+
export function dispatchTargetOf(
|
|
78
|
+
definition: Record<string, any>,
|
|
79
|
+
slot: string,
|
|
80
|
+
): { entry: Record<string, any> | undefined; kind: string; name: string } | undefined {
|
|
81
|
+
const value = definition[slot];
|
|
82
|
+
if (!isRefSentinel(value)) return undefined;
|
|
83
|
+
const source = value.source;
|
|
84
|
+
const name = source.startsWith("Self.") ? source.slice("Self.".length) : source;
|
|
85
|
+
const bodies = definition.resources;
|
|
86
|
+
if (!Array.isArray(bodies)) return undefined;
|
|
87
|
+
for (const body of bodies) {
|
|
88
|
+
if (!body || typeof body !== "object") continue;
|
|
89
|
+
const entry = body as Record<string, any>;
|
|
90
|
+
if (entry.metadata?.name !== name || typeof entry.kind !== "string") continue;
|
|
91
|
+
return { entry, kind: entry.kind, name };
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
71
96
|
/** True when a CEL path lies at or inside a body's own subtree. */
|
|
72
97
|
export function pathInBody(path: string, prefix: string): boolean {
|
|
73
98
|
return (
|
|
@@ -6,7 +6,9 @@ import {
|
|
|
6
6
|
parseCanonicalTypeSchemaId,
|
|
7
7
|
} from "@telorun/sdk";
|
|
8
8
|
import { KERNEL_BUILTINS } from "./builtins.js";
|
|
9
|
+
import { moduleAliasScope } from "./module-alias-scope.js";
|
|
9
10
|
import { withRefSlotsAsReadings } from "./ref-slot-reading.js";
|
|
11
|
+
import { dispatchTargetOf } from "./template-body.js";
|
|
10
12
|
// Where CEL is evaluated is one reader (`eval-paths.ts`), and the region half of
|
|
11
13
|
// it moved there so the scope walk and the `x-telo-eval` walk answer the same
|
|
12
14
|
// question in one place. Re-exported: this module is where every existing
|
|
@@ -51,6 +53,9 @@ export interface ContextResolveOpts {
|
|
|
51
53
|
aliases?: {
|
|
52
54
|
resolveKind(kind: string): string | undefined;
|
|
53
55
|
};
|
|
56
|
+
/** Per-declaring-module alias tables, so a kind read off a forwarded
|
|
57
|
+
* definition resolves in the module that wrote it. */
|
|
58
|
+
aliasesByModule?: ReadonlyMap<string, { resolveKind(kind: string): string | undefined }>;
|
|
54
59
|
allManifests?: Record<string, any>[];
|
|
55
60
|
}
|
|
56
61
|
|
|
@@ -334,18 +339,20 @@ function collectionBindingWithheld(
|
|
|
334
339
|
* Example: `properties.self.x-telo-context-from-root: "schema"` reads
|
|
335
340
|
* `manifestRoot.schema` and uses it as the schema of the `self` CEL variable.
|
|
336
341
|
*
|
|
337
|
-
* - `x-telo-context-from-ref-kind`: reads a
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
342
|
+
* - `x-telo-context-from-ref-kind`: reads a KIND from `manifestRoot.<refPath>` —
|
|
343
|
+
* a field holding a kind name, or a definition's dispatch slot holding a
|
|
344
|
+
* `!ref` to a `resources:` entry (whose kind it is) — and returns the
|
|
345
|
+
* `<field>` schema (e.g. `outputType`/`inputType`) the entry itself declares,
|
|
346
|
+
* else the one the kind's definition declares. Used to type `result` against
|
|
347
|
+
* the dispatch target's declared output shape.
|
|
341
348
|
*
|
|
342
349
|
* Syntax: `<refPath>#<field>` — slashes traverse the manifest tree.
|
|
343
350
|
*
|
|
344
|
-
* Example: `x-telo-context-from-ref-kind: "provide
|
|
345
|
-
* `manifestRoot.provide
|
|
346
|
-
*
|
|
351
|
+
* Example: `x-telo-context-from-ref-kind: "provide#outputType"` resolves
|
|
352
|
+
* `manifestRoot.provide` (`!ref source`) to the entry named `source`, and
|
|
353
|
+
* returns its `outputType` schema, or its kind's.
|
|
347
354
|
*
|
|
348
|
-
* Accepts either a single string or an array of strings. With an array,
|
|
355
|
+
* Accepts either a single string or an array of strings. With an array, slots
|
|
349
356
|
* are tried in order and the first one that resolves to a usable schema wins —
|
|
350
357
|
* used by `result:` to find its dispatch target under whichever entry-point
|
|
351
358
|
* field (`provide:` or `invoke:`) the definition declares.
|
|
@@ -370,7 +377,8 @@ export function resolveContextAnnotations(
|
|
|
370
377
|
const normalizedOpts: ContextResolveOpts = Array.isArray(opts)
|
|
371
378
|
? { allManifests: opts }
|
|
372
379
|
: (opts ?? {});
|
|
373
|
-
const { manifestRoot = manifestItem, defs, aliases, allManifests } =
|
|
380
|
+
const { manifestRoot = manifestItem, defs, aliases, aliasesByModule, allManifests } =
|
|
381
|
+
normalizedOpts;
|
|
374
382
|
|
|
375
383
|
const from = schema["x-telo-context-from"] as string | undefined;
|
|
376
384
|
if (from) {
|
|
@@ -459,18 +467,29 @@ export function resolveContextAnnotations(
|
|
|
459
467
|
}
|
|
460
468
|
}
|
|
461
469
|
if (defs) {
|
|
470
|
+
// The kind is read off THIS document, so it is spelled in the alias scope
|
|
471
|
+
// of the module that declared it — a library's dispatch target is written
|
|
472
|
+
// through an import the consumer has no reason to have.
|
|
473
|
+
const scope = moduleAliasScope(
|
|
474
|
+
(manifestRoot as { metadata?: { module?: unknown } }).metadata,
|
|
475
|
+
aliases,
|
|
476
|
+
aliasesByModule,
|
|
477
|
+
);
|
|
462
478
|
for (const fromRefKind of fromRefKinds) {
|
|
463
479
|
const hashIdx = fromRefKind.indexOf("#");
|
|
464
480
|
if (hashIdx <= 0) continue;
|
|
465
481
|
const refPath = fromRefKind.slice(0, hashIdx);
|
|
466
482
|
const field = fromRefKind.slice(hashIdx + 1);
|
|
467
|
-
const
|
|
468
|
-
if (
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
483
|
+
const target = kindAtPath(manifestRoot, refPath);
|
|
484
|
+
if (!target) continue;
|
|
485
|
+
// The entry's own field first — a per-instance narrowing (`outputType:`
|
|
486
|
+
// on the entry) is what the kernel binds — then the kind's.
|
|
487
|
+
const own = target.entry?.[field];
|
|
488
|
+
const ownResolved = own !== undefined ? resolveTypeFieldToSchema(own, allManifests ?? []) : undefined;
|
|
489
|
+
if (ownResolved && typeof ownResolved === "object") return ownResolved;
|
|
490
|
+
const canonical = scope?.resolveKind(target.kind) ?? target.kind;
|
|
491
|
+
const def = defs.resolve(canonical) ?? defs.resolve(target.kind);
|
|
492
|
+
const typeField = def ? (def as Record<string, unknown>)[field] : undefined;
|
|
474
493
|
const resolved = resolveTypeFieldToSchema(typeField, allManifests ?? []);
|
|
475
494
|
if (resolved && typeof resolved === "object") {
|
|
476
495
|
return resolved;
|
|
@@ -597,6 +616,28 @@ function navigatePath(obj: unknown, segments: string[]): unknown {
|
|
|
597
616
|
return cur;
|
|
598
617
|
}
|
|
599
618
|
|
|
619
|
+
/** The kind an `x-telo-context-from-ref-kind` path names: a field holding a
|
|
620
|
+
* kind NAME (`targetKind`), or a definition's dispatch slot holding a `!ref`
|
|
621
|
+
* to a `resources:` entry, which carries the kind — and, where it narrows
|
|
622
|
+
* one, the contract field itself. */
|
|
623
|
+
export function kindAtPath(
|
|
624
|
+
manifestRoot: Record<string, any>,
|
|
625
|
+
refPath: string,
|
|
626
|
+
): { kind: string; entry?: Record<string, any> } | undefined {
|
|
627
|
+
const segments = refPath.split("/");
|
|
628
|
+
const value = navigatePath(manifestRoot, segments);
|
|
629
|
+
if (typeof value === "string") return value.length > 0 ? { kind: value } : undefined;
|
|
630
|
+
// The dispatch-slot reading is a fact about ONE built-in document, not about
|
|
631
|
+
// the annotation: `resources:` is a `Telo.Definition`'s entry list. The
|
|
632
|
+
// annotation itself is generic vocabulary any kind may declare, and without
|
|
633
|
+
// this gate a third-party kind whose own `resources:` array means something
|
|
634
|
+
// else would have a slot silently resolved against it — a wrong answer with
|
|
635
|
+
// nothing reporting it, which is worse than no answer.
|
|
636
|
+
if (segments.length !== 1 || manifestRoot.kind !== "Telo.Definition") return undefined;
|
|
637
|
+
const dispatch = dispatchTargetOf(manifestRoot, segments[0]!);
|
|
638
|
+
return dispatch ? { kind: dispatch.kind, entry: dispatch.entry } : undefined;
|
|
639
|
+
}
|
|
640
|
+
|
|
600
641
|
/**
|
|
601
642
|
* Walk a JSON Schema tree and collect all `x-telo-context` annotations,
|
|
602
643
|
* returning them as `{ scope, schema }` pairs using JSONPath-style scopes —
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { nearestName } from "./nearest-name.js";
|
|
2
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
3
|
+
import type { AliasResolver } from "./alias-resolver.js";
|
|
4
|
+
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
5
|
+
import { parseExportEntry } from "./flatten-for-analyzer.js";
|
|
6
|
+
import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const SOURCE = "telo-analyzer";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A LIBRARY'S EXPORT LIST IS CHECKED WHERE IT IS WRITTEN.
|
|
12
|
+
*
|
|
13
|
+
* `exports.kinds` and `exports.resources` are a library's public contract, and
|
|
14
|
+
* they were read as a GATE only: a listed name resolved when a consumer asked
|
|
15
|
+
* for it, and one that named nothing failed in the consumer's file
|
|
16
|
+
* (`UNDEFINED_KIND` on `kind: L.Client`, with nothing wrong in that file) while
|
|
17
|
+
* the author who could fix it saw a clean check. Both entries are resolved here,
|
|
18
|
+
* against what the library declares:
|
|
19
|
+
*
|
|
20
|
+
* - a bare kind names a `Telo.Definition` / `Telo.Abstract` of this module, and
|
|
21
|
+
* `Alias.Kind` re-exports through an import declared in this file
|
|
22
|
+
* (`EXPORT_KIND_UNKNOWN`). The one mistake that reads as intended — a bare
|
|
23
|
+
* name that IS a kind, of an imported module — gets the re-export spelling
|
|
24
|
+
* in its message, because a clean check was read as confirmation it worked;
|
|
25
|
+
* - a bare instance names a resource declared in this module, and
|
|
26
|
+
* `Alias.name` an instance the aliased import exports
|
|
27
|
+
* (`EXPORT_RESOURCE_UNKNOWN`).
|
|
28
|
+
*
|
|
29
|
+
* Runs on the library as an ENTRY — a consumer's flattened analysis drops the
|
|
30
|
+
* library doc, and the list is not the consumer's to fix. Browser-safe.
|
|
31
|
+
*/
|
|
32
|
+
export function validateExports(
|
|
33
|
+
manifests: ResourceManifest[],
|
|
34
|
+
registry: DefinitionRegistry,
|
|
35
|
+
aliases: AliasResolver,
|
|
36
|
+
rootModules: ReadonlySet<string>,
|
|
37
|
+
): AnalysisDiagnostic[] {
|
|
38
|
+
const out: AnalysisDiagnostic[] = [];
|
|
39
|
+
|
|
40
|
+
for (const lib of manifests) {
|
|
41
|
+
if (lib.kind !== "Telo.Library") continue;
|
|
42
|
+
const moduleName = lib.metadata?.name as string | undefined;
|
|
43
|
+
if (!moduleName || !rootModules.has(moduleName)) continue;
|
|
44
|
+
const exports = (lib as { exports?: { kinds?: unknown; resources?: unknown } }).exports;
|
|
45
|
+
if (!exports || typeof exports !== "object") continue;
|
|
46
|
+
const filePath = (lib.metadata as { source?: string } | undefined)?.source;
|
|
47
|
+
const resource = { kind: lib.kind, name: moduleName };
|
|
48
|
+
|
|
49
|
+
const own = (m: ResourceManifest): boolean => {
|
|
50
|
+
const mod = (m.metadata as { module?: string } | undefined)?.module;
|
|
51
|
+
return mod === undefined || mod === moduleName;
|
|
52
|
+
};
|
|
53
|
+
const declaredKinds = new Set<string>();
|
|
54
|
+
const instances = new Set<string>();
|
|
55
|
+
const importAliases = new Map<string, string | undefined>();
|
|
56
|
+
const forwardedByModule = new Map<string, Set<string>>();
|
|
57
|
+
for (const m of manifests) {
|
|
58
|
+
const name = m.metadata?.name;
|
|
59
|
+
if (typeof name !== "string") continue;
|
|
60
|
+
const meta = m.metadata as {
|
|
61
|
+
module?: string;
|
|
62
|
+
forwardedExport?: boolean;
|
|
63
|
+
resolvedModuleName?: string;
|
|
64
|
+
};
|
|
65
|
+
if (meta.forwardedExport && meta.module) {
|
|
66
|
+
let set = forwardedByModule.get(meta.module);
|
|
67
|
+
if (!set) forwardedByModule.set(meta.module, (set = new Set()));
|
|
68
|
+
set.add(name);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!own(m)) continue;
|
|
72
|
+
if (m.kind === "Telo.Definition" || m.kind === "Telo.Abstract") declaredKinds.add(name);
|
|
73
|
+
else if (m.kind === "Telo.Import") importAliases.set(name, meta.resolvedModuleName);
|
|
74
|
+
else if (m.kind !== "Telo.Library" && m.kind !== "Telo.Application") instances.add(name);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const report = (code: string, path: string, message: string, fix?: string) =>
|
|
78
|
+
out.push({
|
|
79
|
+
severity: DiagnosticSeverity.Error,
|
|
80
|
+
code,
|
|
81
|
+
source: SOURCE,
|
|
82
|
+
message: `Telo.Library/${moduleName}: ${message}`,
|
|
83
|
+
data: { resource, filePath, path, ...(fix ? { fix: { replacement: fix } } : {}) },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const kinds = exports.kinds;
|
|
87
|
+
if (Array.isArray(kinds)) {
|
|
88
|
+
kinds.forEach((entry, i) => {
|
|
89
|
+
if (typeof entry !== "string") return;
|
|
90
|
+
const path = `exports.kinds[${i}]`;
|
|
91
|
+
const { alias, name } = parseExportEntry(entry);
|
|
92
|
+
if (alias) {
|
|
93
|
+
if (!importAliases.has(alias)) {
|
|
94
|
+
report(
|
|
95
|
+
"EXPORT_KIND_UNKNOWN",
|
|
96
|
+
path,
|
|
97
|
+
`'exports.kinds: ${entry}' re-exports through '${alias}', which is not an import ` +
|
|
98
|
+
`of this library. Imports: ${[...importAliases.keys()].join(", ") || "(none)"}.`,
|
|
99
|
+
);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const canonical = aliases.resolveKind(entry);
|
|
103
|
+
if (canonical && !registry.resolve(canonical)) {
|
|
104
|
+
report(
|
|
105
|
+
"EXPORT_KIND_UNKNOWN",
|
|
106
|
+
path,
|
|
107
|
+
`'exports.kinds: ${entry}' names no kind '${name}' exported by '${alias}'.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (declaredKinds.has(name)) return;
|
|
113
|
+
// The natural first attempt at a re-export: the imported kind's bare
|
|
114
|
+
// suffix. It resolves as a kind — just not as one this library owns.
|
|
115
|
+
const viaImport = [...importAliases.keys()].find((a) => {
|
|
116
|
+
const canonical = aliases.resolveKind(`${a}.${name}`);
|
|
117
|
+
return canonical !== undefined && registry.resolve(canonical) !== undefined;
|
|
118
|
+
});
|
|
119
|
+
if (viaImport) {
|
|
120
|
+
report(
|
|
121
|
+
"EXPORT_KIND_UNKNOWN",
|
|
122
|
+
path,
|
|
123
|
+
`'exports.kinds: ${name}' is not a kind this library declares — it is ` +
|
|
124
|
+
`'${viaImport}.${name}', an imported kind. Re-export it as '${viaImport}.${name}', ` +
|
|
125
|
+
`or declare a local kind that extends it.`,
|
|
126
|
+
`${viaImport}.${name}`,
|
|
127
|
+
);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const suggestion = nearestName(name, [...declaredKinds]);
|
|
131
|
+
report(
|
|
132
|
+
"EXPORT_KIND_UNKNOWN",
|
|
133
|
+
path,
|
|
134
|
+
`'exports.kinds: ${name}' names no Telo.Definition or Telo.Abstract of this library. ` +
|
|
135
|
+
`Declared: ${[...declaredKinds].join(", ") || "(none)"}.` +
|
|
136
|
+
(suggestion ? ` Did you mean '${suggestion}'?` : ""),
|
|
137
|
+
suggestion,
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const resources = exports.resources;
|
|
143
|
+
if (Array.isArray(resources)) {
|
|
144
|
+
resources.forEach((entry, i) => {
|
|
145
|
+
if (typeof entry !== "string") return;
|
|
146
|
+
const path = `exports.resources[${i}]`;
|
|
147
|
+
const { alias, name } = parseExportEntry(entry);
|
|
148
|
+
if (alias) {
|
|
149
|
+
if (!importAliases.has(alias)) {
|
|
150
|
+
report(
|
|
151
|
+
"EXPORT_RESOURCE_UNKNOWN",
|
|
152
|
+
path,
|
|
153
|
+
`'exports.resources: ${entry}' re-exports through '${alias}', which is not an ` +
|
|
154
|
+
`import of this library. Imports: ${[...importAliases.keys()].join(", ") || "(none)"}.`,
|
|
155
|
+
);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const targetModule = importAliases.get(alias);
|
|
159
|
+
const exported = targetModule ? forwardedByModule.get(targetModule) : undefined;
|
|
160
|
+
if (exported && !exported.has(name)) {
|
|
161
|
+
report(
|
|
162
|
+
"EXPORT_RESOURCE_UNKNOWN",
|
|
163
|
+
path,
|
|
164
|
+
`'exports.resources: ${entry}' names no instance '${name}' exported by '${alias}'. ` +
|
|
165
|
+
`Exported: ${[...exported].join(", ") || "(none)"}.`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (instances.has(name)) return;
|
|
171
|
+
const suggestion = nearestName(name, [...instances]);
|
|
172
|
+
report(
|
|
173
|
+
"EXPORT_RESOURCE_UNKNOWN",
|
|
174
|
+
path,
|
|
175
|
+
`'exports.resources: ${name}' names no resource declared in this library. ` +
|
|
176
|
+
`Declared: ${[...instances].join(", ") || "(none)"}.` +
|
|
177
|
+
(suggestion ? ` Did you mean '${suggestion}'?` : ""),
|
|
178
|
+
suggestion,
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return out;
|
|
185
|
+
}
|
package/src/validate-extends.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { DefinitionRegistry } from "./definition-registry.js";
|
|
|
4
4
|
import {
|
|
5
5
|
controllerBearingAncestor,
|
|
6
6
|
effectiveAuthorSchema,
|
|
7
|
+
hasOwnControllerOrTemplate,
|
|
7
8
|
inheritedCapability,
|
|
8
9
|
isInheritedDelegation,
|
|
9
10
|
type DefResolver,
|
|
@@ -74,6 +75,44 @@ export function validateExtends(
|
|
|
74
75
|
const resource = { kind: m.kind, name };
|
|
75
76
|
const label = `${m.kind}/${name}`;
|
|
76
77
|
|
|
78
|
+
// `base:` is read on exactly one path — the inherited-controller one, which
|
|
79
|
+
// the kernel takes only when the definition has NO body of its own. A
|
|
80
|
+
// `resources:` block or a dispatch slot selects the template path, and
|
|
81
|
+
// `base:` is then never evaluated: the kind silently became an empty
|
|
82
|
+
// template, and the failure landed in the CONSUMER's resource as a `{}`
|
|
83
|
+
// where a parent instance should have been. Refused here, where the two
|
|
84
|
+
// blocks sit side by side, and at registration in the kernel.
|
|
85
|
+
//
|
|
86
|
+
// WHETHER there is a body is `hasOwnControllerOrTemplate` — the same
|
|
87
|
+
// predicate the kernel branches on, so the two halves cannot disagree about
|
|
88
|
+
// what a body is. Deciding it here with a key scan did disagree: an empty
|
|
89
|
+
// `resources: []` is falsy to a length test and truthy to the kernel's, so
|
|
90
|
+
// it passed `telo check` and threw `ERR_BASE_WITH_TEMPLATE_BODY` at boot —
|
|
91
|
+
// a brand-new guard shipping with the very gap it was written to close. The
|
|
92
|
+
// scan survives for one job only: naming the offending key in the message.
|
|
93
|
+
if ((m as { base?: unknown }).base != null) {
|
|
94
|
+
const hasBody = hasOwnControllerOrTemplate(m as ResourceDefinition);
|
|
95
|
+
const bodyKey =
|
|
96
|
+
(["resources", "controllers", "invoke", "run", "provide", "mount"] as const).find(
|
|
97
|
+
(key) => (m as Record<string, unknown>)[key] !== undefined,
|
|
98
|
+
) ?? "resources";
|
|
99
|
+
if (hasBody) {
|
|
100
|
+
diagnostics.push({
|
|
101
|
+
severity: DiagnosticSeverity.Error,
|
|
102
|
+
code: "BASE_WITH_TEMPLATE_BODY",
|
|
103
|
+
source: SOURCE,
|
|
104
|
+
message:
|
|
105
|
+
`${label}: 'base:' maps this kind's config onto the inherited controller of ` +
|
|
106
|
+
`'${typeof extendsOf(m) === "string" ? extendsOf(m) : "<no extends>"}', so the ` +
|
|
107
|
+
`definition may not also declare '${bodyKey}:'. With '${bodyKey}:' the kind is a ` +
|
|
108
|
+
`template and 'base:' is never evaluated. Either drop 'base:' and dispatch to a ` +
|
|
109
|
+
`'resources:' entry with '!ref', or drop '${bodyKey}:' and build the parent's config ` +
|
|
110
|
+
`in 'base:' alone.`,
|
|
111
|
+
data: { resource, filePath, path: bodyKey },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
77
116
|
// --- extends validation ---
|
|
78
117
|
// At this point `m.extends` is whatever the manifest declared (alias form, e.g.
|
|
79
118
|
// "Ai.Model"). Resolve through the AliasResolver to the canonical form before
|
|
@@ -243,3 +282,7 @@ export function validateExtends(
|
|
|
243
282
|
|
|
244
283
|
return diagnostics;
|
|
245
284
|
}
|
|
285
|
+
|
|
286
|
+
function extendsOf(m: ResourceManifest): unknown {
|
|
287
|
+
return (m as { extends?: unknown }).extends;
|
|
288
|
+
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
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 {
|
|
4
|
+
import {
|
|
5
|
+
controllerBearingAncestor,
|
|
6
|
+
inheritedCapability,
|
|
7
|
+
type DefResolver,
|
|
8
|
+
} from "./extends-resolution.js";
|
|
5
9
|
import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
|
|
6
10
|
|
|
7
11
|
const SOURCE = "telo-analyzer";
|
|
@@ -16,20 +20,16 @@ const SOURCE = "telo-analyzer";
|
|
|
16
20
|
* `capability` is not `Telo.Provider`.
|
|
17
21
|
* - PROVIDE_DISPATCHER_CONFLICT: `provide:` co-exists with `invoke:` or `run:`
|
|
18
22
|
* on the same definition.
|
|
19
|
-
* - PROVIDE_TARGET_UNKNOWN: `provide.name` does not resolve to an entry in
|
|
20
|
-
* `resources:`.
|
|
21
|
-
* - PROVIDE_TARGET_NOT_INVOCABLE: `provide.name` resolves to a resource whose
|
|
22
|
-
* kind is registered but not a `Telo.Invocable`.
|
|
23
23
|
* - PROVIDER_MISSING_IMPLEMENTATION: definition with `capability: Telo.Provider`
|
|
24
24
|
* declares neither `controllers:` (TS-backed) nor `provide:` (template-backed).
|
|
25
25
|
* - MOUNT_ON_NON_MOUNT: `mount:` declared on a definition whose `capability` is
|
|
26
26
|
* not `Telo.Mount`.
|
|
27
27
|
* - MOUNT_DISPATCHER_CONFLICT: `mount:` co-exists with another dispatch
|
|
28
28
|
* entry-point (`invoke:` / `run:` / `provide:`).
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
29
|
+
*
|
|
30
|
+
* What a dispatch slot NAMES — that it is a `!ref`, that the entry exists, that
|
|
31
|
+
* its capability carries the method — is `validate-template-body`'s, for every
|
|
32
|
+
* slot alike.
|
|
33
33
|
*/
|
|
34
34
|
export function validateProviderCoherence(
|
|
35
35
|
manifests: ResourceManifest[],
|
|
@@ -57,13 +57,19 @@ export function validateProviderCoherence(
|
|
|
57
57
|
const label = `${m.kind}/${name}`;
|
|
58
58
|
|
|
59
59
|
const md = m as Record<string, unknown>;
|
|
60
|
-
const
|
|
60
|
+
const resolveDef: DefResolver = (k) =>
|
|
61
|
+
registry.resolve(aliases.resolveKind(k) ?? k) ?? registry.resolve(k);
|
|
62
|
+
// The INHERITED capability, not the declared one: an `extends` child writes
|
|
63
|
+
// none of its own, so reading `md.capability` reported a child that inherits
|
|
64
|
+
// `Telo.Provider` and declares `provide:` as PROVIDE_ON_NON_PROVIDER
|
|
65
|
+
// "(found '<unset>')" — the same defect `celRuleApplies` and
|
|
66
|
+
// `validate-template-body` were fixed for, in the file next door.
|
|
67
|
+
const capability = inheritedCapability(m as ResourceDefinition, resolveDef);
|
|
61
68
|
const provide = md.provide;
|
|
62
69
|
const invoke = md.invoke;
|
|
63
70
|
const run = md.run;
|
|
64
71
|
const mount = md.mount;
|
|
65
72
|
const controllers = md.controllers;
|
|
66
|
-
const resources = md.resources;
|
|
67
73
|
|
|
68
74
|
const hasProvide = provide !== undefined && provide !== null;
|
|
69
75
|
const hasInvoke = invoke !== undefined && invoke !== null;
|
|
@@ -96,70 +102,6 @@ export function validateProviderCoherence(
|
|
|
96
102
|
});
|
|
97
103
|
}
|
|
98
104
|
|
|
99
|
-
if (hasProvide && typeof provide === "object" && !Array.isArray(provide)) {
|
|
100
|
-
const provideObj = provide as { kind?: unknown; name?: unknown };
|
|
101
|
-
const providedName = typeof provideObj.name === "string" ? provideObj.name : undefined;
|
|
102
|
-
const providedKind = typeof provideObj.kind === "string" ? provideObj.kind : undefined;
|
|
103
|
-
if (providedName && Array.isArray(resources)) {
|
|
104
|
-
const match = resources.find((r) => {
|
|
105
|
-
const meta = (r as { metadata?: { name?: unknown } })?.metadata;
|
|
106
|
-
return typeof meta?.name === "string" && meta.name === providedName;
|
|
107
|
-
}) as { kind?: unknown } | undefined;
|
|
108
|
-
if (!match) {
|
|
109
|
-
diagnostics.push({
|
|
110
|
-
severity: DiagnosticSeverity.Error,
|
|
111
|
-
code: "PROVIDE_TARGET_UNKNOWN",
|
|
112
|
-
source: SOURCE,
|
|
113
|
-
message:
|
|
114
|
-
`${label}: 'provide.name: ${providedName}' does not match any entry's ` +
|
|
115
|
-
`metadata.name in 'resources:'.`,
|
|
116
|
-
data: { resource, filePath, path: "provide.name" },
|
|
117
|
-
});
|
|
118
|
-
} else if (typeof match.kind === "string") {
|
|
119
|
-
// `provide.kind` is the type contract the analyzer uses to type
|
|
120
|
-
// `result` CEL against the target's `outputType`. The runtime
|
|
121
|
-
// dispatches on `provide.name` and ignores `provide.kind`, so a
|
|
122
|
-
// mismatch silently degrades `result` typing to an open schema
|
|
123
|
-
// (and at runtime quietly invokes the actually-matched resource).
|
|
124
|
-
// Flag the divergence so result-typing never lies.
|
|
125
|
-
if (providedKind) {
|
|
126
|
-
const providedCanonical = aliases.resolveKind(providedKind) ?? providedKind;
|
|
127
|
-
const matchCanonical = aliases.resolveKind(match.kind) ?? match.kind;
|
|
128
|
-
if (providedCanonical !== matchCanonical) {
|
|
129
|
-
diagnostics.push({
|
|
130
|
-
severity: DiagnosticSeverity.Error,
|
|
131
|
-
code: "PROVIDE_KIND_MISMATCH",
|
|
132
|
-
source: SOURCE,
|
|
133
|
-
message:
|
|
134
|
-
`${label}: 'provide.kind: ${providedKind}' disagrees with the matched ` +
|
|
135
|
-
`'resources:' entry's kind '${match.kind}' (matched by metadata.name ` +
|
|
136
|
-
`'${providedName}'). The runtime dispatches by name, so 'provide.kind' ` +
|
|
137
|
-
`is decorative — but the analyzer types 'result:' against it, and a ` +
|
|
138
|
-
`mismatch silently turns off that typing.`,
|
|
139
|
-
data: { resource, filePath, path: "provide.kind" },
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
const resolvedKind = aliases.resolveKind(match.kind) ?? match.kind;
|
|
144
|
-
const targetDef = registry.resolve(resolvedKind) ?? registry.resolve(match.kind);
|
|
145
|
-
if (targetDef && targetDef.kind === "Telo.Definition") {
|
|
146
|
-
const targetCap = (targetDef as { capability?: unknown }).capability;
|
|
147
|
-
if (typeof targetCap === "string" && targetCap !== "Telo.Invocable") {
|
|
148
|
-
diagnostics.push({
|
|
149
|
-
severity: DiagnosticSeverity.Error,
|
|
150
|
-
code: "PROVIDE_TARGET_NOT_INVOCABLE",
|
|
151
|
-
source: SOURCE,
|
|
152
|
-
message:
|
|
153
|
-
`${label}: 'provide.name: ${providedName}' resolves to a ${match.kind} ` +
|
|
154
|
-
`(capability '${targetCap}'); 'provide:' requires a Telo.Invocable target.`,
|
|
155
|
-
data: { resource, filePath, path: "provide.name" },
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
105
|
if (hasMount && capability !== "Telo.Mount") {
|
|
164
106
|
diagnostics.push({
|
|
165
107
|
severity: DiagnosticSeverity.Error,
|
|
@@ -185,61 +127,9 @@ export function validateProviderCoherence(
|
|
|
185
127
|
});
|
|
186
128
|
}
|
|
187
129
|
|
|
188
|
-
if (hasMount) {
|
|
189
|
-
// Resolve the target's name from either form: the bare string (the
|
|
190
|
-
// primary, documented form — `mount: api`) or the object's `name`. A CEL
|
|
191
|
-
// target (`${{ … }}`) can only be checked at runtime, so skip those.
|
|
192
|
-
let mountedName: string | undefined;
|
|
193
|
-
if (typeof mount === "string") {
|
|
194
|
-
if (!mount.includes("${{")) mountedName = mount;
|
|
195
|
-
} else if (typeof mount === "object" && !Array.isArray(mount)) {
|
|
196
|
-
const mountObj = mount as { name?: unknown };
|
|
197
|
-
if (typeof mountObj.name === "string" && !mountObj.name.includes("${{")) {
|
|
198
|
-
mountedName = mountObj.name;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
const mountPath = typeof mount === "string" ? "mount" : "mount.name";
|
|
202
|
-
if (mountedName && Array.isArray(resources)) {
|
|
203
|
-
const match = resources.find((r) => {
|
|
204
|
-
const meta = (r as { metadata?: { name?: unknown } })?.metadata;
|
|
205
|
-
return typeof meta?.name === "string" && meta.name === mountedName;
|
|
206
|
-
}) as { kind?: unknown } | undefined;
|
|
207
|
-
if (!match) {
|
|
208
|
-
diagnostics.push({
|
|
209
|
-
severity: DiagnosticSeverity.Error,
|
|
210
|
-
code: "MOUNT_TARGET_UNKNOWN",
|
|
211
|
-
source: SOURCE,
|
|
212
|
-
message:
|
|
213
|
-
`${label}: '${mountPath}: ${mountedName}' does not match any entry's ` +
|
|
214
|
-
`metadata.name in 'resources:'.`,
|
|
215
|
-
data: { resource, filePath, path: mountPath },
|
|
216
|
-
});
|
|
217
|
-
} else if (typeof match.kind === "string") {
|
|
218
|
-
const resolvedKind = aliases.resolveKind(match.kind) ?? match.kind;
|
|
219
|
-
const targetDef = registry.resolve(resolvedKind) ?? registry.resolve(match.kind);
|
|
220
|
-
if (targetDef && targetDef.kind === "Telo.Definition") {
|
|
221
|
-
const targetCap = (targetDef as { capability?: unknown }).capability;
|
|
222
|
-
if (typeof targetCap === "string" && targetCap !== "Telo.Mount") {
|
|
223
|
-
diagnostics.push({
|
|
224
|
-
severity: DiagnosticSeverity.Error,
|
|
225
|
-
code: "MOUNT_TARGET_NOT_MOUNTABLE",
|
|
226
|
-
source: SOURCE,
|
|
227
|
-
message:
|
|
228
|
-
`${label}: '${mountPath}: ${mountedName}' resolves to a ${match.kind} ` +
|
|
229
|
-
`(capability '${targetCap}'); 'mount:' requires a Telo.Mount target.`,
|
|
230
|
-
data: { resource, filePath, path: mountPath },
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
130
|
// A definition that inherits a controller by delegation (concrete `extends`,
|
|
239
131
|
// no own controller/template) satisfies the implementation requirement
|
|
240
132
|
// 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
133
|
const inheritsController =
|
|
244
134
|
typeof md.extends === "string" &&
|
|
245
135
|
controllerBearingAncestor(m as ResourceDefinition, resolveDef) !== undefined;
|