@telorun/analyzer 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +2 -2
- package/README.md +3 -3
- package/dist/adapters/http-adapter.d.ts +10 -0
- package/dist/adapters/http-adapter.d.ts.map +1 -0
- package/dist/adapters/http-adapter.js +18 -0
- package/dist/adapters/node-adapter.d.ts +17 -0
- package/dist/adapters/node-adapter.d.ts.map +1 -0
- package/dist/adapters/node-adapter.js +71 -0
- package/dist/adapters/registry-adapter.d.ts +15 -0
- package/dist/adapters/registry-adapter.d.ts.map +1 -0
- package/dist/adapters/registry-adapter.js +53 -0
- package/dist/analysis-registry.d.ts +7 -0
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +38 -0
- package/dist/analyzer.d.ts +15 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +114 -10
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +58 -1
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +16 -0
- package/dist/dependency-graph.d.ts.map +1 -1
- package/dist/dependency-graph.js +27 -13
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/kernel-globals.d.ts.map +1 -1
- package/dist/kernel-globals.js +9 -11
- package/dist/manifest-loader.d.ts +23 -1
- package/dist/manifest-loader.d.ts.map +1 -1
- package/dist/manifest-loader.js +66 -3
- package/dist/normalize-inline-resources.d.ts.map +1 -1
- package/dist/normalize-inline-resources.js +26 -14
- package/dist/position-metadata.d.ts +11 -2
- package/dist/position-metadata.d.ts.map +1 -1
- package/dist/position-metadata.js +18 -3
- package/dist/precompile.d.ts.map +1 -1
- package/dist/precompile.js +9 -1
- package/dist/reference-field-map.d.ts +21 -4
- package/dist/reference-field-map.d.ts.map +1 -1
- package/dist/reference-field-map.js +93 -25
- package/dist/residual-schema.d.ts +23 -0
- package/dist/residual-schema.d.ts.map +1 -0
- package/dist/residual-schema.js +45 -0
- package/dist/resolve-ref-sentinels.d.ts +27 -0
- package/dist/resolve-ref-sentinels.d.ts.map +1 -0
- package/dist/resolve-ref-sentinels.js +114 -0
- package/dist/rewrite-synthetic-origins.d.ts +10 -0
- package/dist/rewrite-synthetic-origins.d.ts.map +1 -0
- package/dist/rewrite-synthetic-origins.js +55 -0
- package/dist/schema-compat.d.ts +7 -1
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +19 -2
- package/dist/system-kinds.d.ts +25 -0
- package/dist/system-kinds.d.ts.map +1 -0
- package/dist/system-kinds.js +34 -0
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validate-cel-context.d.ts +5 -0
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +27 -15
- package/dist/validate-provider-coherence.d.ts +23 -0
- package/dist/validate-provider-coherence.d.ts.map +1 -0
- package/dist/validate-provider-coherence.js +148 -0
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +141 -36
- package/dist/with-synthetic-positions.d.ts +28 -0
- package/dist/with-synthetic-positions.d.ts.map +1 -0
- package/dist/with-synthetic-positions.js +45 -0
- package/package.json +7 -4
- package/src/analysis-registry.ts +37 -0
- package/src/analyzer.ts +118 -12
- package/src/builtins.ts +58 -1
- package/src/definition-registry.ts +15 -0
- package/src/dependency-graph.ts +27 -14
- package/src/index.ts +2 -0
- package/src/kernel-globals.ts +9 -11
- package/src/manifest-loader.ts +69 -4
- package/src/normalize-inline-resources.ts +48 -13
- package/src/position-metadata.ts +18 -3
- package/src/precompile.ts +8 -1
- package/src/reference-field-map.ts +129 -24
- package/src/residual-schema.ts +49 -0
- package/src/resolve-ref-sentinels.ts +127 -0
- package/src/rewrite-synthetic-origins.ts +75 -0
- package/src/schema-compat.ts +19 -2
- package/src/system-kinds.ts +37 -0
- package/src/types.ts +12 -0
- package/src/validate-cel-context.ts +28 -15
- package/src/validate-provider-coherence.ts +166 -0
- package/src/validate-references.ts +138 -35
- package/src/with-synthetic-positions.ts +48 -0
package/src/schema-compat.ts
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import AjvModule from "ajv";
|
|
2
2
|
import addFormats from "ajv-formats";
|
|
3
|
-
import { isTaggedSentinel } from "@telorun/templating";
|
|
3
|
+
import { isRefSentinel, isTaggedSentinel, ManifestRootSchema } from "@telorun/templating";
|
|
4
4
|
|
|
5
5
|
const Ajv = (AjvModule as any).default ?? AjvModule;
|
|
6
6
|
|
|
7
7
|
/** Creates a configured AJV instance (allErrors, strict: false, with formats).
|
|
8
|
-
*
|
|
8
|
+
* Also registers the kernel manifest root schema under `telo://manifest` so
|
|
9
|
+
* module YAMLs can `$ref` into the shared `$defs/ResourceRef` (and any future
|
|
10
|
+
* shared fragments) from this analyzer's AJV without each module having to
|
|
11
|
+
* bundle its own copy.
|
|
12
|
+
*
|
|
13
|
+
* Called once for the module-level instance and once per
|
|
14
|
+
* DefinitionRegistry instance. */
|
|
9
15
|
export function createAjv(): InstanceType<typeof Ajv> {
|
|
10
16
|
const instance = new Ajv({ allErrors: true, strict: false });
|
|
11
17
|
(addFormats as any).default
|
|
12
18
|
? (addFormats as any).default(instance)
|
|
13
19
|
: (addFormats as any)(instance);
|
|
20
|
+
instance.addSchema(ManifestRootSchema);
|
|
14
21
|
return instance;
|
|
15
22
|
}
|
|
16
23
|
|
|
@@ -301,6 +308,16 @@ export function substituteCelFields(
|
|
|
301
308
|
if (typeof data === "string" && CEL_PURE_RE.test(data)) {
|
|
302
309
|
return celPlaceholderForSchema(resolved);
|
|
303
310
|
}
|
|
311
|
+
// `!ref <name>` sentinels are identity markers, not runtime values —
|
|
312
|
+
// schemas that opt into `$ref: "telo://manifest#/$defs/ResourceRef"`
|
|
313
|
+
// (or `anyOf` it alongside other shapes) need the actual sentinel
|
|
314
|
+
// object so AJV validates it against ResourceRefSchema. Collapsing it
|
|
315
|
+
// to a CEL placeholder would either fail the schema (when the slot
|
|
316
|
+
// expects the ResourceRef shape) or mask validation errors (when the
|
|
317
|
+
// slot expects something else entirely).
|
|
318
|
+
if (isRefSentinel(data)) {
|
|
319
|
+
return data;
|
|
320
|
+
}
|
|
304
321
|
if (isTaggedSentinel(data)) {
|
|
305
322
|
return celPlaceholderForSchema(resolved);
|
|
306
323
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Resource-kind sets used by analysis passes to decide what counts as a
|
|
2
|
+
* user-defined instance vs. a system-level blueprint. Pulled into one
|
|
3
|
+
* place so the three passes (reference validation, dependency graph,
|
|
4
|
+
* ref-sentinel resolution) don't drift; each pass exports its own
|
|
5
|
+
* scoped view with a comment explaining what's in and what's out. */
|
|
6
|
+
|
|
7
|
+
/** Skipped by reference validation: type blueprints whose own ref slots
|
|
8
|
+
* belong to a different phase (definition schema validation rather than
|
|
9
|
+
* per-resource validation). Telo.Application and Telo.Library
|
|
10
|
+
* intentionally fall through — Application has `targets` (real refs) and
|
|
11
|
+
* Library is harmless (no ref-bearing fields). Telo.Import is also
|
|
12
|
+
* intentionally not skipped — its `source` is not an x-telo-ref slot, so
|
|
13
|
+
* walking it is cheap and consistent. */
|
|
14
|
+
export const REF_VALIDATION_SKIP_KINDS: ReadonlySet<string> = new Set([
|
|
15
|
+
"Telo.Definition",
|
|
16
|
+
"Telo.Abstract",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/** Excluded from the dependency graph: kinds that are not runtime nodes.
|
|
20
|
+
* Telo.Abstract is intentionally not in this set today — abstracts have
|
|
21
|
+
* no resource manifests, so they never reach graph construction; if
|
|
22
|
+
* that ever changes, add it explicitly. */
|
|
23
|
+
export const DEPENDENCY_GRAPH_SKIP_KINDS: ReadonlySet<string> = new Set([
|
|
24
|
+
"Telo.Definition",
|
|
25
|
+
"Telo.Import",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** Skipped by `!ref` sentinel resolution: kinds whose bodies are
|
|
29
|
+
* blueprints or import-time metadata, not resource instances with
|
|
30
|
+
* user-referenced ref slots. Mirrors `REF_VALIDATION_SKIP_KINDS` but
|
|
31
|
+
* also drops Telo.Import (its `source` isn't a ref slot, and walking
|
|
32
|
+
* the field map on it is pointless since there's no registered kind). */
|
|
33
|
+
export const REF_RESOLUTION_SKIP_KINDS: ReadonlySet<string> = new Set([
|
|
34
|
+
"Telo.Definition",
|
|
35
|
+
"Telo.Abstract",
|
|
36
|
+
"Telo.Import",
|
|
37
|
+
]);
|
package/src/types.ts
CHANGED
|
@@ -81,6 +81,18 @@ export interface LoaderInitOptions {
|
|
|
81
81
|
|
|
82
82
|
export interface AnalysisOptions {
|
|
83
83
|
strictContexts?: boolean;
|
|
84
|
+
/** When true, `analyze()` runs the state-mutating setup (module identity /
|
|
85
|
+
* alias / definition registration plus `normalizeInlineResources`) but
|
|
86
|
+
* skips every diagnostic-producing pass — per-resource validation, the
|
|
87
|
+
* Library `env:` check, `validateExtends`, `validateProviderCoherence`,
|
|
88
|
+
* and `validateThrowsCoverage`. Used by the kernel when a previous load
|
|
89
|
+
* has already stamped the manifest set as valid (by content hash), so
|
|
90
|
+
* the registry still gets populated without paying the validation walk
|
|
91
|
+
* on every cold start. The caller takes responsibility for the
|
|
92
|
+
* correctness guarantee — pass this only when something durable
|
|
93
|
+
* (on-disk stamp) attests that the manifests passed a real analyze
|
|
94
|
+
* pass at the same analyzer / kernel version. */
|
|
95
|
+
skipValidation?: boolean;
|
|
84
96
|
}
|
|
85
97
|
|
|
86
98
|
/** Pre-seeded state for incremental analysis. Passed to StaticAnalyzer.analyze() so it does
|
|
@@ -119,6 +119,11 @@ export function pathMatchesScope(exprPath: string, scope: string): boolean {
|
|
|
119
119
|
* `manifestRoot.provide.kind` as a kind name, looks up the kind's Telo.Definition,
|
|
120
120
|
* and returns the `outputType` schema.
|
|
121
121
|
*
|
|
122
|
+
* Accepts either a single string or an array of strings. With an array, paths
|
|
123
|
+
* are tried in order and the first one that resolves to a usable schema wins —
|
|
124
|
+
* used by `result:` to find its dispatch target under whichever entry-point
|
|
125
|
+
* field (`provide:` or `invoke:`) the definition declares.
|
|
126
|
+
*
|
|
122
127
|
* - `x-telo-context-ref-from`: existing form — reads `{kind, name}` object from
|
|
123
128
|
* `manifestItem.<path>`, looks up the named manifest, returns its `<subpath>` field.
|
|
124
129
|
*
|
|
@@ -153,8 +158,16 @@ export function resolveContextAnnotations(
|
|
|
153
158
|
}
|
|
154
159
|
|
|
155
160
|
const fromRoot = schema["x-telo-context-from-root"] as string | undefined;
|
|
156
|
-
const
|
|
157
|
-
|
|
161
|
+
const fromRefKindRaw = schema["x-telo-context-from-ref-kind"] as
|
|
162
|
+
| string
|
|
163
|
+
| string[]
|
|
164
|
+
| undefined;
|
|
165
|
+
const fromRefKinds = fromRefKindRaw == null
|
|
166
|
+
? []
|
|
167
|
+
: Array.isArray(fromRefKindRaw)
|
|
168
|
+
? fromRefKindRaw
|
|
169
|
+
: [fromRefKindRaw];
|
|
170
|
+
if (fromRoot || fromRefKinds.length > 0) {
|
|
158
171
|
if (fromRoot) {
|
|
159
172
|
const resolved = navigatePath(manifestRoot, fromRoot.split("/")) as
|
|
160
173
|
| Record<string, any>
|
|
@@ -163,22 +176,22 @@ export function resolveContextAnnotations(
|
|
|
163
176
|
return resolved;
|
|
164
177
|
}
|
|
165
178
|
}
|
|
166
|
-
if (
|
|
167
|
-
const
|
|
168
|
-
|
|
179
|
+
if (defs) {
|
|
180
|
+
for (const fromRefKind of fromRefKinds) {
|
|
181
|
+
const hashIdx = fromRefKind.indexOf("#");
|
|
182
|
+
if (hashIdx <= 0) continue;
|
|
169
183
|
const refPath = fromRefKind.slice(0, hashIdx);
|
|
170
184
|
const field = fromRefKind.slice(hashIdx + 1);
|
|
171
185
|
const kindValue = navigatePath(manifestRoot, refPath.split("/"));
|
|
172
|
-
if (typeof kindValue
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
186
|
+
if (typeof kindValue !== "string" || kindValue.length === 0) continue;
|
|
187
|
+
const canonical = aliases?.resolveKind(kindValue) ?? kindValue;
|
|
188
|
+
const def = defs.resolve(canonical);
|
|
189
|
+
const typeField = def
|
|
190
|
+
? (def as Record<string, unknown>)[field]
|
|
191
|
+
: undefined;
|
|
192
|
+
const resolved = resolveTypeFieldToSchema(typeField, allManifests ?? []);
|
|
193
|
+
if (resolved && typeof resolved === "object") {
|
|
194
|
+
return resolved;
|
|
182
195
|
}
|
|
183
196
|
}
|
|
184
197
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import type { AliasResolver } from "./alias-resolver.js";
|
|
3
|
+
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
4
|
+
import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const SOURCE = "telo-analyzer";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Validates coherence rules for `Telo.Definition` documents that use the `provide:`
|
|
10
|
+
* template target, plus the implementation-presence rule on `Telo.Provider`
|
|
11
|
+
* definitions.
|
|
12
|
+
*
|
|
13
|
+
* Diagnostics:
|
|
14
|
+
* - PROVIDE_ON_NON_PROVIDER: `provide:` declared on a definition whose
|
|
15
|
+
* `capability` is not `Telo.Provider`.
|
|
16
|
+
* - PROVIDE_DISPATCHER_CONFLICT: `provide:` co-exists with `invoke:` or `run:`
|
|
17
|
+
* on the same definition.
|
|
18
|
+
* - PROVIDE_TARGET_UNKNOWN: `provide.name` does not resolve to an entry in
|
|
19
|
+
* `resources:`.
|
|
20
|
+
* - PROVIDE_TARGET_NOT_INVOCABLE: `provide.name` resolves to a resource whose
|
|
21
|
+
* kind is registered but not a `Telo.Invocable`.
|
|
22
|
+
* - PROVIDER_MISSING_IMPLEMENTATION: definition with `capability: Telo.Provider`
|
|
23
|
+
* declares neither `controllers:` (TS-backed) nor `provide:` (template-backed).
|
|
24
|
+
*/
|
|
25
|
+
export function validateProviderCoherence(
|
|
26
|
+
manifests: ResourceManifest[],
|
|
27
|
+
registry: DefinitionRegistry,
|
|
28
|
+
aliases: AliasResolver,
|
|
29
|
+
): AnalysisDiagnostic[] {
|
|
30
|
+
const diagnostics: AnalysisDiagnostic[] = [];
|
|
31
|
+
|
|
32
|
+
const importedModules = new Set<string>();
|
|
33
|
+
for (const m of manifests) {
|
|
34
|
+
if (m.kind !== "Telo.Import") continue;
|
|
35
|
+
const resolved = (m.metadata as { resolvedModuleName?: string } | undefined)
|
|
36
|
+
?.resolvedModuleName;
|
|
37
|
+
if (resolved) importedModules.add(resolved);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const m of manifests) {
|
|
41
|
+
if (m.kind !== "Telo.Definition") continue;
|
|
42
|
+
const name = m.metadata?.name as string | undefined;
|
|
43
|
+
if (!name) continue;
|
|
44
|
+
const ownModule = (m.metadata as { module?: string } | undefined)?.module;
|
|
45
|
+
if (ownModule && importedModules.has(ownModule)) continue;
|
|
46
|
+
const filePath = (m.metadata as { source?: string } | undefined)?.source;
|
|
47
|
+
const resource = { kind: m.kind, name };
|
|
48
|
+
const label = `${m.kind}/${name}`;
|
|
49
|
+
|
|
50
|
+
const md = m as Record<string, unknown>;
|
|
51
|
+
const capability = typeof md.capability === "string" ? md.capability : undefined;
|
|
52
|
+
const provide = md.provide;
|
|
53
|
+
const invoke = md.invoke;
|
|
54
|
+
const run = md.run;
|
|
55
|
+
const controllers = md.controllers;
|
|
56
|
+
const resources = md.resources;
|
|
57
|
+
|
|
58
|
+
const hasProvide = provide !== undefined && provide !== null;
|
|
59
|
+
const hasInvoke = invoke !== undefined && invoke !== null;
|
|
60
|
+
const hasRun = run !== undefined && run !== null;
|
|
61
|
+
const hasControllers = Array.isArray(controllers) && controllers.length > 0;
|
|
62
|
+
|
|
63
|
+
if (hasProvide && capability !== "Telo.Provider") {
|
|
64
|
+
diagnostics.push({
|
|
65
|
+
severity: DiagnosticSeverity.Error,
|
|
66
|
+
code: "PROVIDE_ON_NON_PROVIDER",
|
|
67
|
+
source: SOURCE,
|
|
68
|
+
message:
|
|
69
|
+
`${label}: 'provide:' is only valid on definitions with 'capability: Telo.Provider' ` +
|
|
70
|
+
`(found '${capability ?? "<unset>"}'). Use 'invoke:' or 'run:' for other capabilities.`,
|
|
71
|
+
data: { resource, filePath, path: "provide" },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (hasProvide && (hasInvoke || hasRun)) {
|
|
76
|
+
const conflict = hasInvoke ? "invoke" : "run";
|
|
77
|
+
diagnostics.push({
|
|
78
|
+
severity: DiagnosticSeverity.Error,
|
|
79
|
+
code: "PROVIDE_DISPATCHER_CONFLICT",
|
|
80
|
+
source: SOURCE,
|
|
81
|
+
message:
|
|
82
|
+
`${label}: 'provide:' cannot co-exist with '${conflict}:'. ` +
|
|
83
|
+
`A definition declares exactly one dispatch entry-point.`,
|
|
84
|
+
data: { resource, filePath, path: "provide" },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (hasProvide && typeof provide === "object" && !Array.isArray(provide)) {
|
|
89
|
+
const provideObj = provide as { kind?: unknown; name?: unknown };
|
|
90
|
+
const providedName = typeof provideObj.name === "string" ? provideObj.name : undefined;
|
|
91
|
+
const providedKind = typeof provideObj.kind === "string" ? provideObj.kind : undefined;
|
|
92
|
+
if (providedName && Array.isArray(resources)) {
|
|
93
|
+
const match = resources.find((r) => {
|
|
94
|
+
const meta = (r as { metadata?: { name?: unknown } })?.metadata;
|
|
95
|
+
return typeof meta?.name === "string" && meta.name === providedName;
|
|
96
|
+
}) as { kind?: unknown } | undefined;
|
|
97
|
+
if (!match) {
|
|
98
|
+
diagnostics.push({
|
|
99
|
+
severity: DiagnosticSeverity.Error,
|
|
100
|
+
code: "PROVIDE_TARGET_UNKNOWN",
|
|
101
|
+
source: SOURCE,
|
|
102
|
+
message:
|
|
103
|
+
`${label}: 'provide.name: ${providedName}' does not match any entry's ` +
|
|
104
|
+
`metadata.name in 'resources:'.`,
|
|
105
|
+
data: { resource, filePath, path: "provide.name" },
|
|
106
|
+
});
|
|
107
|
+
} else if (typeof match.kind === "string") {
|
|
108
|
+
// `provide.kind` is the type contract the analyzer uses to type
|
|
109
|
+
// `result` CEL against the target's `outputType`. The runtime
|
|
110
|
+
// dispatches on `provide.name` and ignores `provide.kind`, so a
|
|
111
|
+
// mismatch silently degrades `result` typing to an open schema
|
|
112
|
+
// (and at runtime quietly invokes the actually-matched resource).
|
|
113
|
+
// Flag the divergence so result-typing never lies.
|
|
114
|
+
if (providedKind) {
|
|
115
|
+
const providedCanonical = aliases.resolveKind(providedKind) ?? providedKind;
|
|
116
|
+
const matchCanonical = aliases.resolveKind(match.kind) ?? match.kind;
|
|
117
|
+
if (providedCanonical !== matchCanonical) {
|
|
118
|
+
diagnostics.push({
|
|
119
|
+
severity: DiagnosticSeverity.Error,
|
|
120
|
+
code: "PROVIDE_KIND_MISMATCH",
|
|
121
|
+
source: SOURCE,
|
|
122
|
+
message:
|
|
123
|
+
`${label}: 'provide.kind: ${providedKind}' disagrees with the matched ` +
|
|
124
|
+
`'resources:' entry's kind '${match.kind}' (matched by metadata.name ` +
|
|
125
|
+
`'${providedName}'). The runtime dispatches by name, so 'provide.kind' ` +
|
|
126
|
+
`is decorative — but the analyzer types 'result:' against it, and a ` +
|
|
127
|
+
`mismatch silently turns off that typing.`,
|
|
128
|
+
data: { resource, filePath, path: "provide.kind" },
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const resolvedKind = aliases.resolveKind(match.kind) ?? match.kind;
|
|
133
|
+
const targetDef = registry.resolve(resolvedKind) ?? registry.resolve(match.kind);
|
|
134
|
+
if (targetDef && targetDef.kind === "Telo.Definition") {
|
|
135
|
+
const targetCap = (targetDef as { capability?: unknown }).capability;
|
|
136
|
+
if (typeof targetCap === "string" && targetCap !== "Telo.Invocable") {
|
|
137
|
+
diagnostics.push({
|
|
138
|
+
severity: DiagnosticSeverity.Error,
|
|
139
|
+
code: "PROVIDE_TARGET_NOT_INVOCABLE",
|
|
140
|
+
source: SOURCE,
|
|
141
|
+
message:
|
|
142
|
+
`${label}: 'provide.name: ${providedName}' resolves to a ${match.kind} ` +
|
|
143
|
+
`(capability '${targetCap}'); 'provide:' requires a Telo.Invocable target.`,
|
|
144
|
+
data: { resource, filePath, path: "provide.name" },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (capability === "Telo.Provider" && !hasControllers && !hasProvide) {
|
|
153
|
+
diagnostics.push({
|
|
154
|
+
severity: DiagnosticSeverity.Error,
|
|
155
|
+
code: "PROVIDER_MISSING_IMPLEMENTATION",
|
|
156
|
+
source: SOURCE,
|
|
157
|
+
message:
|
|
158
|
+
`${label}: 'capability: Telo.Provider' requires either 'controllers:' ` +
|
|
159
|
+
`(TS-backed) or 'provide:' (template-backed) to declare an implementation.`,
|
|
160
|
+
data: { resource, filePath, path: "capability" },
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return diagnostics;
|
|
166
|
+
}
|
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
-
import {
|
|
2
|
+
import { isRefSentinel } from "@telorun/templating";
|
|
3
|
+
import { isRefEntry, isScopeEntry, isSchemaFromEntry, isInlineResource, resolveFieldEntries, resolveFieldValues, type RefFieldEntry } from "./reference-field-map.js";
|
|
3
4
|
import { navigateJsonPointer } from "./schema-compat.js";
|
|
5
|
+
import { REF_VALIDATION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
|
|
4
6
|
import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisContext } from "./types.js";
|
|
5
7
|
import type { AliasResolver } from "./alias-resolver.js";
|
|
6
8
|
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
7
9
|
|
|
8
10
|
const SOURCE = "telo-analyzer";
|
|
9
|
-
/** Kinds skipped by reference validation. Telo.Application and Telo.Library
|
|
10
|
-
* are intentionally not here: Application has `targets` with x-telo-ref that
|
|
11
|
-
* must be validated, and Library has no ref-bearing fields so flows through
|
|
12
|
-
* harmlessly. Telo.Import is also not here for the same reason — its
|
|
13
|
-
* `source` field isn't x-telo-ref, so nothing gets checked. */
|
|
14
|
-
const SYSTEM_KINDS = new Set(["Telo.Definition", "Telo.Abstract"]);
|
|
15
11
|
|
|
16
12
|
/**
|
|
17
13
|
* Checks whether `kind` satisfies the ref constraint in `entry`.
|
|
@@ -76,13 +72,88 @@ export function validateReferences(
|
|
|
76
72
|
const aliasesByModule = context.aliasesByModule;
|
|
77
73
|
if (!aliases || !registry) return diagnostics;
|
|
78
74
|
|
|
79
|
-
// Build outer resource lookup by name for resolution check
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
75
|
+
// Build outer resource lookup by name for resolution check, collecting
|
|
76
|
+
// every entry per name so we can surface name collisions as diagnostics
|
|
77
|
+
// (the kernel's resource registry shares one namespace across all
|
|
78
|
+
// non-system kinds — e.g. `Telo.Application HelloApi` and `Http.Api
|
|
79
|
+
// HelloApi` collide at boot with `ERR_DUPLICATE_RESOURCE`. Catching it
|
|
80
|
+
// statically removes a class of "everything analyzes clean, then the
|
|
81
|
+
// kernel refuses to start" surprises.)
|
|
82
|
+
//
|
|
83
|
+
// Telo.Import is excluded from the duplicate check on top of the
|
|
84
|
+
// SYSTEM_KINDS skip: its `metadata.name` is an alias, not a resource
|
|
85
|
+
// identity (aliases live in a separate namespace from resources, and
|
|
86
|
+
// colliding aliases vs. resource names is benign — the alias is only
|
|
87
|
+
// ever read as a kind prefix).
|
|
88
|
+
// Group manifests by name to detect collisions. Two subtleties:
|
|
89
|
+
//
|
|
90
|
+
// 1. Some analyzer hosts emit the SAME physical document twice through
|
|
91
|
+
// their pipeline — e.g. the telo-editor's `toAnalysisManifests` walks
|
|
92
|
+
// each workspace module's documents independently, and a file
|
|
93
|
+
// reachable from two angles (entry module + `include:` partial)
|
|
94
|
+
// shows up twice. The fingerprint includes `sourceLine` so identical
|
|
95
|
+
// docs (same kind, name, source, AND source line) collapse to one,
|
|
96
|
+
// while two textually-separate documents in the same file (different
|
|
97
|
+
// source lines) keep separate fingerprints and trip the diagnostic.
|
|
98
|
+
// 2. The diagnostic carries a precomputed `range` pointing at the
|
|
99
|
+
// duplicate's source line — editor hosts that resolve diagnostic
|
|
100
|
+
// positions via a `${file}::${kind}::${name}` lookup would otherwise
|
|
101
|
+
// collide on duplicates (Map.set overwrites) and place the squiggle
|
|
102
|
+
// ambiguously. The explicit `range` short-circuits that lookup.
|
|
103
|
+
// Dedup pipeline echoes — the same physical document emitted twice
|
|
104
|
+
// through an analyzer host's pipeline. Keyed on (kind, name, source,
|
|
105
|
+
// sourceLine), so two textually-distinct docs in the same file (same
|
|
106
|
+
// source, different sourceLine) keep separate fingerprints and still
|
|
107
|
+
// trip the diagnostic. `analyze()` enforces that every non-system
|
|
108
|
+
// manifest carries both positional fields — no defensive guard needed.
|
|
109
|
+
const byNameAll = new Map<string, ResourceManifest[]>();
|
|
110
|
+
const seen = new Set<string>();
|
|
83
111
|
for (const r of resources) {
|
|
84
|
-
if (r.metadata?.name
|
|
112
|
+
if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || r.kind === "Telo.Import") continue;
|
|
113
|
+
const name = r.metadata.name as string;
|
|
114
|
+
// `analyze()` guarantees both fields are present on non-system manifests.
|
|
115
|
+
const meta = r.metadata as unknown as { source: string; sourceLine: number };
|
|
116
|
+
const fingerprint = `${r.kind} ${name} ${meta.source} ${meta.sourceLine}`;
|
|
117
|
+
if (seen.has(fingerprint)) continue;
|
|
118
|
+
seen.add(fingerprint);
|
|
119
|
+
const existing = byNameAll.get(name);
|
|
120
|
+
if (existing) existing.push(r);
|
|
121
|
+
else byNameAll.set(name, [r]);
|
|
122
|
+
}
|
|
123
|
+
for (const [name, list] of byNameAll) {
|
|
124
|
+
if (list.length <= 1) continue;
|
|
125
|
+
const [first, ...rest] = list;
|
|
126
|
+
const firstLabel = `${first.kind}/${name}`;
|
|
127
|
+
for (const dup of rest) {
|
|
128
|
+
const dupMeta = dup.metadata as { source?: string; sourceLine?: number } | undefined;
|
|
129
|
+
const range =
|
|
130
|
+
typeof dupMeta?.sourceLine === "number"
|
|
131
|
+
? {
|
|
132
|
+
start: { line: dupMeta.sourceLine, character: 0 },
|
|
133
|
+
end: { line: dupMeta.sourceLine, character: Number.MAX_SAFE_INTEGER },
|
|
134
|
+
}
|
|
135
|
+
: undefined;
|
|
136
|
+
diagnostics.push({
|
|
137
|
+
severity: DiagnosticSeverity.Error,
|
|
138
|
+
code: "DUPLICATE_RESOURCE_NAME",
|
|
139
|
+
source: SOURCE,
|
|
140
|
+
message: `${dup.kind}/${name}: resource name collides with ${firstLabel} declared earlier (kernel runtime would fail with ERR_DUPLICATE_RESOURCE)`,
|
|
141
|
+
...(range ? { range } : {}),
|
|
142
|
+
data: {
|
|
143
|
+
resource: { kind: dup.kind, name },
|
|
144
|
+
filePath: dupMeta?.source,
|
|
145
|
+
path: "metadata.name",
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
85
149
|
}
|
|
150
|
+
// Single-resource map for the resolution / scope lookups below — when a
|
|
151
|
+
// collision exists, falling back to the first occurrence keeps the rest
|
|
152
|
+
// of the pass behaving the same as before the duplicate diagnostic was
|
|
153
|
+
// added (resolution still finds *something*; the duplicate diagnostic
|
|
154
|
+
// is what surfaces the underlying problem to the user).
|
|
155
|
+
const byName = new Map<string, ResourceManifest>();
|
|
156
|
+
for (const [name, list] of byNameAll) byName.set(name, list[0]);
|
|
86
157
|
|
|
87
158
|
for (const r of resources) {
|
|
88
159
|
if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
|
|
@@ -142,9 +213,41 @@ export function validateReferences(
|
|
|
142
213
|
}
|
|
143
214
|
}
|
|
144
215
|
|
|
145
|
-
for (const val of
|
|
216
|
+
for (const { value: val, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
|
|
146
217
|
if (!val) continue;
|
|
147
218
|
|
|
219
|
+
// `!ref <name>` sentinel — bare resource name marked at parse time as a
|
|
220
|
+
// reference. Look it up against the slot's x-telo-ref constraint exactly
|
|
221
|
+
// like the legacy bare-string path; the only difference is the value's
|
|
222
|
+
// shape (a TaggedSentinel rather than a raw string), which removed the
|
|
223
|
+
// string/inline ambiguity at the source.
|
|
224
|
+
if (isRefSentinel(val)) {
|
|
225
|
+
const refName = val.source;
|
|
226
|
+
const target =
|
|
227
|
+
byName.get(refName) ?? visibleScopeManifests.find((m) => m.metadata?.name === refName);
|
|
228
|
+
if (!target) {
|
|
229
|
+
diagnostics.push({
|
|
230
|
+
severity: DiagnosticSeverity.Error,
|
|
231
|
+
code: "UNRESOLVED_REFERENCE",
|
|
232
|
+
source: SOURCE,
|
|
233
|
+
message: `${resourceLabel}: reference at '${concretePath}' → resource '${refName}' not found`,
|
|
234
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
235
|
+
});
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const kindErrors = checkKind(target.kind as string, entry, registry, aliases);
|
|
239
|
+
if (kindErrors.length > 0) {
|
|
240
|
+
diagnostics.push({
|
|
241
|
+
severity: DiagnosticSeverity.Error,
|
|
242
|
+
code: "REFERENCE_KIND_MISMATCH",
|
|
243
|
+
source: SOURCE,
|
|
244
|
+
message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
|
|
245
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
148
251
|
// Name-only reference (plain string) — look up by name to validate.
|
|
149
252
|
// Qualified references use "Kind.Name" format (e.g. "Http.Api.PaymentApi");
|
|
150
253
|
// extract the resource name from the last dot segment.
|
|
@@ -166,8 +269,8 @@ export function validateReferences(
|
|
|
166
269
|
severity: DiagnosticSeverity.Error,
|
|
167
270
|
code: "UNRESOLVED_REFERENCE",
|
|
168
271
|
source: SOURCE,
|
|
169
|
-
message: `${resourceLabel}: reference at '${
|
|
170
|
-
data: { resource: resourceData, filePath, path:
|
|
272
|
+
message: `${resourceLabel}: reference at '${concretePath}' → resource '${val}' not found`,
|
|
273
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
171
274
|
});
|
|
172
275
|
continue;
|
|
173
276
|
}
|
|
@@ -177,8 +280,8 @@ export function validateReferences(
|
|
|
177
280
|
severity: DiagnosticSeverity.Error,
|
|
178
281
|
code: "REFERENCE_KIND_MISMATCH",
|
|
179
282
|
source: SOURCE,
|
|
180
|
-
message: `${resourceLabel}: reference at '${
|
|
181
|
-
data: { resource: resourceData, filePath, path:
|
|
283
|
+
message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
|
|
284
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
182
285
|
});
|
|
183
286
|
}
|
|
184
287
|
continue;
|
|
@@ -196,8 +299,8 @@ export function validateReferences(
|
|
|
196
299
|
severity: DiagnosticSeverity.Error,
|
|
197
300
|
code: "INVALID_REFERENCE",
|
|
198
301
|
source: SOURCE,
|
|
199
|
-
message: `${resourceLabel}: reference at '${
|
|
200
|
-
data: { resource: resourceData, filePath, path:
|
|
302
|
+
message: `${resourceLabel}: reference at '${concretePath}' must have string 'kind' and 'name' fields`,
|
|
303
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
201
304
|
});
|
|
202
305
|
continue;
|
|
203
306
|
}
|
|
@@ -209,8 +312,8 @@ export function validateReferences(
|
|
|
209
312
|
severity: DiagnosticSeverity.Error,
|
|
210
313
|
code: "REFERENCE_KIND_MISMATCH",
|
|
211
314
|
source: SOURCE,
|
|
212
|
-
message: `${resourceLabel}: reference at '${
|
|
213
|
-
data: { resource: resourceData, filePath, path:
|
|
315
|
+
message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
|
|
316
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
214
317
|
});
|
|
215
318
|
}
|
|
216
319
|
|
|
@@ -223,8 +326,8 @@ export function validateReferences(
|
|
|
223
326
|
severity: DiagnosticSeverity.Error,
|
|
224
327
|
code: "UNRESOLVED_REFERENCE",
|
|
225
328
|
source: SOURCE,
|
|
226
|
-
message: `${resourceLabel}: reference at '${
|
|
227
|
-
data: { resource: resourceData, filePath, path:
|
|
329
|
+
message: `${resourceLabel}: reference at '${concretePath}' → resource '${refVal.name}' not found`,
|
|
330
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
228
331
|
});
|
|
229
332
|
}
|
|
230
333
|
}
|
|
@@ -317,7 +420,7 @@ export function validateReferences(
|
|
|
317
420
|
continue;
|
|
318
421
|
}
|
|
319
422
|
|
|
320
|
-
for (const fieldValue of
|
|
423
|
+
for (const { value: fieldValue, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
|
|
321
424
|
if (fieldValue == null) continue;
|
|
322
425
|
const issues = registry.validateWithRefs(fieldValue, subSchema as Record<string, any>);
|
|
323
426
|
for (const issue of issues) {
|
|
@@ -325,8 +428,8 @@ export function validateReferences(
|
|
|
325
428
|
severity: DiagnosticSeverity.Error,
|
|
326
429
|
code: "DEPENDENT_SCHEMA_MISMATCH",
|
|
327
430
|
source: SOURCE,
|
|
328
|
-
message: `${resourceLabel}: '${
|
|
329
|
-
data: { resource: resourceData, filePath, path:
|
|
431
|
+
message: `${resourceLabel}: '${concretePath}' does not match schema from '${anchorName}${jsonPointer}': ${issue}`,
|
|
432
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
330
433
|
});
|
|
331
434
|
}
|
|
332
435
|
}
|
|
@@ -347,10 +450,10 @@ export function validateReferences(
|
|
|
347
450
|
const anchorValues = resolveFieldValues(r, anchorPath);
|
|
348
451
|
if (anchorValues.length === 0) continue; // anchor field not set — nothing to validate
|
|
349
452
|
|
|
350
|
-
const
|
|
453
|
+
const fieldEntries = resolveFieldEntries(r, fieldPath);
|
|
351
454
|
|
|
352
|
-
for (let i = 0; i <
|
|
353
|
-
const fieldValue =
|
|
455
|
+
for (let i = 0; i < fieldEntries.length; i++) {
|
|
456
|
+
const { value: fieldValue, path: concretePath } = fieldEntries[i];
|
|
354
457
|
if (fieldValue == null) continue;
|
|
355
458
|
|
|
356
459
|
// For absolute paths, the single anchor applies to all field values.
|
|
@@ -367,8 +470,8 @@ export function validateReferences(
|
|
|
367
470
|
severity: DiagnosticSeverity.Error,
|
|
368
471
|
code: "SCHEMA_FROM_MISSING_PATH",
|
|
369
472
|
source: SOURCE,
|
|
370
|
-
message: `${resourceLabel}: x-telo-schema-from at '${
|
|
371
|
-
data: { resource: resourceData, filePath, path:
|
|
473
|
+
message: `${resourceLabel}: x-telo-schema-from at '${concretePath}' → kind '${refVal.kind}' has no schema`,
|
|
474
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
372
475
|
});
|
|
373
476
|
continue;
|
|
374
477
|
}
|
|
@@ -379,8 +482,8 @@ export function validateReferences(
|
|
|
379
482
|
severity: DiagnosticSeverity.Error,
|
|
380
483
|
code: "SCHEMA_FROM_MISSING_PATH",
|
|
381
484
|
source: SOURCE,
|
|
382
|
-
message: `${resourceLabel}: x-telo-schema-from at '${
|
|
383
|
-
data: { resource: resourceData, filePath, path:
|
|
485
|
+
message: `${resourceLabel}: x-telo-schema-from at '${concretePath}' → kind '${refVal.kind}' has no schema path '${jsonPointer}'`,
|
|
486
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
384
487
|
});
|
|
385
488
|
continue;
|
|
386
489
|
}
|
|
@@ -391,8 +494,8 @@ export function validateReferences(
|
|
|
391
494
|
severity: DiagnosticSeverity.Error,
|
|
392
495
|
code: "DEPENDENT_SCHEMA_MISMATCH",
|
|
393
496
|
source: SOURCE,
|
|
394
|
-
message: `${resourceLabel}: '${
|
|
395
|
-
data: { resource: resourceData, filePath, path:
|
|
497
|
+
message: `${resourceLabel}: '${concretePath}' does not match schema from '${refVal.kind}${jsonPointer}': ${issue}`,
|
|
498
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
396
499
|
});
|
|
397
500
|
}
|
|
398
501
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { REF_VALIDATION_SKIP_KINDS } from "./system-kinds.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stamp `metadata.source` and `metadata.sourceLine` on every non-system
|
|
6
|
+
* manifest that lacks them, returning a new array with cloned `metadata`
|
|
7
|
+
* objects for the affected entries.
|
|
8
|
+
*
|
|
9
|
+
* `StaticAnalyzer.analyze()` requires position info on every non-system
|
|
10
|
+
* manifest (the dedup that backs `DUPLICATE_RESOURCE_NAME` reads
|
|
11
|
+
* `(source, sourceLine)` to distinguish pipeline echoes from real
|
|
12
|
+
* collisions). Production callers — the `Loader`, `flattenForAnalyzer`,
|
|
13
|
+
* the telo-editor's `emitDocsFor`, the VSCode extension — all stamp
|
|
14
|
+
* positions already. This helper is the escape hatch for **programmatic
|
|
15
|
+
* callers** (tests, ad-hoc scripts) that construct `ResourceManifest`
|
|
16
|
+
* literals without going through a loader: it gives every otherwise-naked
|
|
17
|
+
* manifest a synthetic, deterministic position so the analyzer's
|
|
18
|
+
* invariant holds without each test having to spell positions out.
|
|
19
|
+
*
|
|
20
|
+
* The synthetic source defaults to `"<programmatic>"` — override via
|
|
21
|
+
* `source` when a stable, recognisable label helps diagnostic output.
|
|
22
|
+
* Each unstamped manifest gets a unique `sourceLine` (1-based array
|
|
23
|
+
* index) so two real duplicates supplied without positions retain
|
|
24
|
+
* distinct fingerprints and still trip `DUPLICATE_RESOURCE_NAME`.
|
|
25
|
+
*
|
|
26
|
+
* Manifests that already carry `metadata.source` and `metadata.sourceLine`
|
|
27
|
+
* pass through unchanged.
|
|
28
|
+
*/
|
|
29
|
+
export function withSyntheticPositions(
|
|
30
|
+
manifests: ResourceManifest[],
|
|
31
|
+
source: string = "<programmatic>",
|
|
32
|
+
): ResourceManifest[] {
|
|
33
|
+
return manifests.map((m, i) => {
|
|
34
|
+
if (REF_VALIDATION_SKIP_KINDS.has(m.kind)) return m;
|
|
35
|
+
const meta = m.metadata as { source?: string; sourceLine?: number } | undefined;
|
|
36
|
+
const hasSource = typeof meta?.source === "string" && meta.source.length > 0;
|
|
37
|
+
const hasLine = typeof meta?.sourceLine === "number";
|
|
38
|
+
if (hasSource && hasLine) return m;
|
|
39
|
+
return {
|
|
40
|
+
...m,
|
|
41
|
+
metadata: {
|
|
42
|
+
...m.metadata,
|
|
43
|
+
source: hasSource ? meta!.source : source,
|
|
44
|
+
sourceLine: hasLine ? meta!.sourceLine : i,
|
|
45
|
+
},
|
|
46
|
+
} as ResourceManifest;
|
|
47
|
+
});
|
|
48
|
+
}
|