@telorun/analyzer 0.20.0 → 0.22.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/README.md +9 -17
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +11 -2
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +6 -7
- package/dist/reference-field-map.js +2 -0
- package/dist/resolve-ref-sentinels.d.ts +22 -7
- package/dist/resolve-ref-sentinels.d.ts.map +1 -1
- package/dist/resolve-ref-sentinels.js +47 -79
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +28 -8
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +5 -0
- package/dist/validate-reference-forms.d.ts +28 -0
- package/dist/validate-reference-forms.d.ts.map +1 -0
- package/dist/validate-reference-forms.js +91 -0
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +4 -37
- package/package.json +4 -4
- package/src/analyzer.ts +12 -8
- package/src/builtins.ts +6 -7
- package/src/reference-field-map.ts +1 -0
- package/src/resolve-ref-sentinels.ts +41 -78
- package/src/schema-compat.ts +27 -8
- package/src/validate-cel-context.ts +5 -0
- package/src/validate-reference-forms.ts +110 -0
- package/src/validate-references.ts +4 -39
package/README.md
CHANGED
|
@@ -55,11 +55,11 @@ metadata:
|
|
|
55
55
|
A complete feedback collection REST API — no code, pure YAML.
|
|
56
56
|
Persists entries to SQLite and serves them over HTTP.
|
|
57
57
|
imports:
|
|
58
|
-
Http: std/http-server@0.
|
|
59
|
-
Sql: std/sql@0.
|
|
58
|
+
Http: std/http-server@0.9.0
|
|
59
|
+
Sql: std/sql@0.8.0
|
|
60
60
|
targets:
|
|
61
|
-
- Migrations
|
|
62
|
-
- Server
|
|
61
|
+
- !ref Migrations
|
|
62
|
+
- !ref Server
|
|
63
63
|
---
|
|
64
64
|
# SQLite database — swap driver/host/database for PostgreSQL with zero YAML changes
|
|
65
65
|
kind: Sql.Connection
|
|
@@ -72,9 +72,7 @@ file: ./tmp/feedback.db
|
|
|
72
72
|
kind: Sql.Migrations
|
|
73
73
|
metadata:
|
|
74
74
|
name: Migrations
|
|
75
|
-
connection:
|
|
76
|
-
kind: Sql.Connection
|
|
77
|
-
name: Db
|
|
75
|
+
connection: !ref Db
|
|
78
76
|
---
|
|
79
77
|
kind: Sql.Migration
|
|
80
78
|
metadata:
|
|
@@ -101,7 +99,7 @@ openapi:
|
|
|
101
99
|
version: 1.0.0
|
|
102
100
|
mounts:
|
|
103
101
|
- path: /v1
|
|
104
|
-
|
|
102
|
+
mount: !ref FeedbackRoutes
|
|
105
103
|
---
|
|
106
104
|
kind: Http.Api
|
|
107
105
|
metadata:
|
|
@@ -123,9 +121,7 @@ routes:
|
|
|
123
121
|
required: [ text ]
|
|
124
122
|
handler:
|
|
125
123
|
kind: Sql.Exec
|
|
126
|
-
connection:
|
|
127
|
-
kind: Sql.Connection
|
|
128
|
-
name: Db
|
|
124
|
+
connection: !ref Db
|
|
129
125
|
inputs:
|
|
130
126
|
sql: "INSERT INTO feedback (text, source, score) VALUES (?, ?, ?)"
|
|
131
127
|
bindings:
|
|
@@ -146,9 +142,7 @@ routes:
|
|
|
146
142
|
method: GET
|
|
147
143
|
handler:
|
|
148
144
|
kind: Sql.Select
|
|
149
|
-
connection:
|
|
150
|
-
kind: Sql.Connection
|
|
151
|
-
name: Db
|
|
145
|
+
connection: !ref Db
|
|
152
146
|
from: feedback
|
|
153
147
|
columns: [ id, text, source, score, created_at ]
|
|
154
148
|
orderBy:
|
|
@@ -172,9 +166,7 @@ routes:
|
|
|
172
166
|
required: [ id ]
|
|
173
167
|
handler:
|
|
174
168
|
kind: Sql.Select
|
|
175
|
-
connection:
|
|
176
|
-
kind: Sql.Connection
|
|
177
|
-
name: Db
|
|
169
|
+
connection: !ref Db
|
|
178
170
|
from: feedback
|
|
179
171
|
columns: [ id, text, source, score, created_at ]
|
|
180
172
|
where:
|
package/dist/analyzer.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAIzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,sBAAsB,CAAC;AAiB9B,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAIzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,sBAAsB,CAAC;AAiB9B,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA4hB/F,MAAM,WAAW,qBAAqB;IACpC,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B;AAED,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,OAAO,GAAE,qBAA0B;IAI/C;;;;;;;;;;;;;;OAcG;IACH,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAkqBvB,aAAa,CACX,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAMvB,SAAS,CACP,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,gBAAgB,EAI1B,kBAAkB,CAAC,EAAE,gBAAgB,EAAE,GACtC,gBAAgB,EAAE;IAerB,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,gBAAgB,GACzB;QAAE,WAAW,EAAE,kBAAkB,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;QAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;CAsB5F"}
|
package/dist/analyzer.js
CHANGED
|
@@ -18,6 +18,7 @@ import { validateExtends } from "./validate-extends.js";
|
|
|
18
18
|
import { validateNestedInlineResources } from "./validate-nested-inline.js";
|
|
19
19
|
import { validateProviderCoherence } from "./validate-provider-coherence.js";
|
|
20
20
|
import { validateReferences } from "./validate-references.js";
|
|
21
|
+
import { validateReferenceForms } from "./validate-reference-forms.js";
|
|
21
22
|
import { validateUnusedDeclarations } from "./validate-unused-declarations.js";
|
|
22
23
|
import { validateThrowsCoverage } from "./validate-throws-coverage.js";
|
|
23
24
|
const SELF_PREFIX = "Self.";
|
|
@@ -646,13 +647,21 @@ export class StaticAnalyzer {
|
|
|
646
647
|
: def;
|
|
647
648
|
defs.register(normalized);
|
|
648
649
|
}
|
|
650
|
+
// Reference-form validation — enforce `!ref` as the only reference shape.
|
|
651
|
+
// Runs on the RAW manifests, BEFORE inline extraction and sentinel
|
|
652
|
+
// resolution, while an author-written `{kind, name}` is still
|
|
653
|
+
// distinguishable from the resolver's own substitution (after Phase 2/2.5
|
|
654
|
+
// they are the same object).
|
|
655
|
+
if (!options?.skipValidation) {
|
|
656
|
+
diagnostics.push(...validateReferenceForms(manifests, defs, aliases, aliasesByModule));
|
|
657
|
+
}
|
|
649
658
|
// Phase 2: extract inline resources from x-telo-ref slots into first-class manifests
|
|
650
659
|
const allManifests = normalizeInlineResources(manifests, defs, aliases, aliasesByModule);
|
|
651
660
|
// Phase 2.5: resolve `!ref <name>` sentinels at every ref slot to canonical
|
|
652
661
|
// {kind, name} objects so downstream phases (validation, dependency graph,
|
|
653
662
|
// kernel controllers) see a uniform shape. Runs after normalize so both
|
|
654
663
|
// original and inline-extracted manifests have their sentinels resolved.
|
|
655
|
-
resolveRefSentinels(allManifests,
|
|
664
|
+
resolveRefSentinels(allManifests, aliases, aliasesByModule);
|
|
656
665
|
// Trusted-input fast path: when the caller has already attested that
|
|
657
666
|
// this exact manifest set passes analysis (e.g. via the kernel's
|
|
658
667
|
// hash-stamped `.validated.json` cache), skip the validation walk.
|
|
@@ -1058,7 +1067,7 @@ export class StaticAnalyzer {
|
|
|
1058
1067
|
// Resolve !ref sentinels after normalize so both the original and
|
|
1059
1068
|
// inline-extracted manifests get their refs canonicalized to
|
|
1060
1069
|
// {kind, name} for the kernel that consumes this output.
|
|
1061
|
-
resolveRefSentinels(normalized, ctx.
|
|
1070
|
+
resolveRefSentinels(normalized, ctx.aliases, ctx.aliasesByModule, crossModuleTargets ?? []);
|
|
1062
1071
|
return normalized;
|
|
1063
1072
|
}
|
|
1064
1073
|
prepare(manifests, registry) {
|
package/dist/builtins.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"builtins.d.ts","sourceRoot":"","sources":["../src/builtins.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEvD,eAAO,MAAM,eAAe,EAAE,kBAAkB,
|
|
1
|
+
{"version":3,"file":"builtins.d.ts","sourceRoot":"","sources":["../src/builtins.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEvD,eAAO,MAAM,eAAe,EAAE,kBAAkB,EAqd/C,CAAC"}
|
package/dist/builtins.js
CHANGED
|
@@ -234,7 +234,7 @@ export const KERNEL_BUILTINS = [
|
|
|
234
234
|
},
|
|
235
235
|
// Gated reference: run() a Runnable/Service only when the
|
|
236
236
|
// `when` CEL guard holds. Discriminated by the `ref` key. `ref`
|
|
237
|
-
// is a
|
|
237
|
+
// is a `!ref` that resolves to the `{ kind, name }` shape below.
|
|
238
238
|
{
|
|
239
239
|
type: "object",
|
|
240
240
|
required: ["ref"],
|
|
@@ -262,12 +262,11 @@ export const KERNEL_BUILTINS = [
|
|
|
262
262
|
// with an optional `name` (for steps.<name>.result plumbing),
|
|
263
263
|
// `when` guard, and `inputs`. Discriminated by the `invoke` key.
|
|
264
264
|
// Control flow (if/while/switch/try) is not available here —
|
|
265
|
-
// reach for Run.Sequence. `invoke` is ref-only
|
|
266
|
-
// to
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
// Run.Sequence invoke steps.
|
|
265
|
+
// reach for Run.Sequence. `invoke` is ref-only: a `!ref` that
|
|
266
|
+
// resolves to the `{ kind, name }` shape below. Requiring `name`
|
|
267
|
+
// rejects an inline `{ kind }` definition (no name) at analysis
|
|
268
|
+
// instead of failing at boot with an undefined resource name. The
|
|
269
|
+
// Invocable/Runnable kind set mirrors Run.Sequence invoke steps.
|
|
271
270
|
{
|
|
272
271
|
type: "object",
|
|
273
272
|
required: ["invoke"],
|
|
@@ -175,6 +175,8 @@ function traverseNode(node, path, map, root, visitedRefs = new Set()) {
|
|
|
175
175
|
}
|
|
176
176
|
return;
|
|
177
177
|
}
|
|
178
|
+
if (typeof node?.$ref === "string")
|
|
179
|
+
return;
|
|
178
180
|
// Array — recurse into items
|
|
179
181
|
if (node.type === "array" && node.items) {
|
|
180
182
|
traverseNode(node.items, path + "[]", map, root, visitedRefs);
|
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
2
|
import type { AliasResolver } from "./alias-resolver.js";
|
|
3
|
-
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
4
3
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Rewrites every `!ref <name>` sentinel in each non-system resource's value tree
|
|
5
|
+
* to `{kind, name}` (local) or `{kind, name, alias}` (cross-module), in place.
|
|
6
|
+
*
|
|
7
|
+
* The walk is value-tree-driven, not field-map-driven: a `!ref` tag is an
|
|
8
|
+
* *explicit* reference marker, so any sentinel found anywhere is unambiguously a
|
|
9
|
+
* reference and is resolved. This reaches sites the field map intentionally does
|
|
10
|
+
* not descend — notably `Run.Sequence` step `invoke`s (behind a local `$ref`)
|
|
11
|
+
* and references nested inside inline definitions — so every downstream consumer
|
|
12
|
+
* (Phase-5 injection, the runtime controllers, the analyzer's step-context and
|
|
13
|
+
* dependency passes) sees the uniform `{kind, name}` shape regardless of where
|
|
14
|
+
* the reference was written.
|
|
15
|
+
*
|
|
16
|
+
* Resolving a sentinel here does NOT cause Phase-5 injection: that pass is
|
|
17
|
+
* driven by the field map, which still excludes step `invoke`s, so a resolved
|
|
18
|
+
* step invoke stays `{kind, name}` and is dispatched through
|
|
19
|
+
* `executeInvokeStep` (preserving `<Kind>.<Name>.Invoked` events) rather than
|
|
20
|
+
* being replaced with a live instance.
|
|
8
21
|
*
|
|
9
22
|
* Reference grammar — the tag's source string is split on the FIRST dot:
|
|
10
23
|
* - `!ref writeLine` → local resource `writeLine`
|
|
@@ -14,12 +27,14 @@ import type { DefinitionRegistry } from "./definition-registry.js";
|
|
|
14
27
|
*
|
|
15
28
|
* Aliases are PascalCase identifiers without dots and resource names carry no dots
|
|
16
29
|
* (enforced as a hard diagnostic), so the first-dot split is unambiguous. When the
|
|
17
|
-
* name doesn't resolve
|
|
18
|
-
*
|
|
30
|
+
* name doesn't resolve (e.g. a scope-local target, or a cross-module reference in
|
|
31
|
+
* partial single-file analysis), the sentinel is left in place — the runtime
|
|
32
|
+
* resolves scope-local names on demand, and `validateReferences` emits the
|
|
33
|
+
* `UNRESOLVED_REFERENCE` diagnostic for genuine misses.
|
|
19
34
|
*
|
|
20
35
|
* Forwarded foreign resources (an imported library's exported instances, carrying a
|
|
21
36
|
* `metadata.module` that isn't a root module) are resolution TARGETS only — they are not
|
|
22
37
|
* re-walked as sources here, since their own ref slots belong to their own module scope.
|
|
23
38
|
*/
|
|
24
|
-
export declare function resolveRefSentinels(resources: ResourceManifest[],
|
|
39
|
+
export declare function resolveRefSentinels(resources: ResourceManifest[], aliases?: AliasResolver, aliasesByModule?: Map<string, AliasResolver>, crossModuleTargets?: ResourceManifest[]): void;
|
|
25
40
|
//# sourceMappingURL=resolve-ref-sentinels.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resolve-ref-sentinels.d.ts","sourceRoot":"","sources":["../src/resolve-ref-sentinels.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"resolve-ref-sentinels.d.ts","sourceRoot":"","sources":["../src/resolve-ref-sentinels.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAOzD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,aAAa,EACvB,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,EAK5C,kBAAkB,GAAE,gBAAgB,EAAO,GAC1C,IAAI,CAgFN"}
|
|
@@ -1,10 +1,23 @@
|
|
|
1
|
-
import { isRefSentinel } from "@telorun/templating";
|
|
2
|
-
import { isRefEntry } from "./reference-field-map.js";
|
|
1
|
+
import { isRefSentinel, isTaggedSentinel } from "@telorun/templating";
|
|
3
2
|
import { REF_RESOLUTION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
|
|
4
3
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Rewrites every `!ref <name>` sentinel in each non-system resource's value tree
|
|
5
|
+
* to `{kind, name}` (local) or `{kind, name, alias}` (cross-module), in place.
|
|
6
|
+
*
|
|
7
|
+
* The walk is value-tree-driven, not field-map-driven: a `!ref` tag is an
|
|
8
|
+
* *explicit* reference marker, so any sentinel found anywhere is unambiguously a
|
|
9
|
+
* reference and is resolved. This reaches sites the field map intentionally does
|
|
10
|
+
* not descend — notably `Run.Sequence` step `invoke`s (behind a local `$ref`)
|
|
11
|
+
* and references nested inside inline definitions — so every downstream consumer
|
|
12
|
+
* (Phase-5 injection, the runtime controllers, the analyzer's step-context and
|
|
13
|
+
* dependency passes) sees the uniform `{kind, name}` shape regardless of where
|
|
14
|
+
* the reference was written.
|
|
15
|
+
*
|
|
16
|
+
* Resolving a sentinel here does NOT cause Phase-5 injection: that pass is
|
|
17
|
+
* driven by the field map, which still excludes step `invoke`s, so a resolved
|
|
18
|
+
* step invoke stays `{kind, name}` and is dispatched through
|
|
19
|
+
* `executeInvokeStep` (preserving `<Kind>.<Name>.Invoked` events) rather than
|
|
20
|
+
* being replaced with a live instance.
|
|
8
21
|
*
|
|
9
22
|
* Reference grammar — the tag's source string is split on the FIRST dot:
|
|
10
23
|
* - `!ref writeLine` → local resource `writeLine`
|
|
@@ -14,14 +27,16 @@ import { REF_RESOLUTION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
|
|
|
14
27
|
*
|
|
15
28
|
* Aliases are PascalCase identifiers without dots and resource names carry no dots
|
|
16
29
|
* (enforced as a hard diagnostic), so the first-dot split is unambiguous. When the
|
|
17
|
-
* name doesn't resolve
|
|
18
|
-
*
|
|
30
|
+
* name doesn't resolve (e.g. a scope-local target, or a cross-module reference in
|
|
31
|
+
* partial single-file analysis), the sentinel is left in place — the runtime
|
|
32
|
+
* resolves scope-local names on demand, and `validateReferences` emits the
|
|
33
|
+
* `UNRESOLVED_REFERENCE` diagnostic for genuine misses.
|
|
19
34
|
*
|
|
20
35
|
* Forwarded foreign resources (an imported library's exported instances, carrying a
|
|
21
36
|
* `metadata.module` that isn't a root module) are resolution TARGETS only — they are not
|
|
22
37
|
* re-walked as sources here, since their own ref slots belong to their own module scope.
|
|
23
38
|
*/
|
|
24
|
-
export function resolveRefSentinels(resources,
|
|
39
|
+
export function resolveRefSentinels(resources, aliases, aliasesByModule,
|
|
25
40
|
// Extra foreign resources used only as cross-module resolution TARGETS (not mutated, not
|
|
26
41
|
// walked as sources). The kernel passes the analyzer-flattened set here so the runtime
|
|
27
42
|
// pass — which loads the entry module only — can still resolve `!ref Alias.name` against
|
|
@@ -81,80 +96,33 @@ crossModuleTargets = []) {
|
|
|
81
96
|
}
|
|
82
97
|
return undefined;
|
|
83
98
|
};
|
|
99
|
+
// Resolve every `!ref` sentinel in the tree; leave opaque tagged / precompiled
|
|
100
|
+
// nodes (e.g. `!cel`) untouched and don't descend into them.
|
|
101
|
+
const walk = (value) => {
|
|
102
|
+
if (isRefSentinel(value)) {
|
|
103
|
+
return resolveTarget(value.source) ?? value;
|
|
104
|
+
}
|
|
105
|
+
if (value === null || typeof value !== "object")
|
|
106
|
+
return value;
|
|
107
|
+
if (isTaggedSentinel(value))
|
|
108
|
+
return value;
|
|
109
|
+
if (value.__compiled)
|
|
110
|
+
return value;
|
|
111
|
+
if (Array.isArray(value)) {
|
|
112
|
+
for (let i = 0; i < value.length; i++)
|
|
113
|
+
value[i] = walk(value[i]);
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
const obj = value;
|
|
117
|
+
for (const key of Object.keys(obj))
|
|
118
|
+
obj[key] = walk(obj[key]);
|
|
119
|
+
return value;
|
|
120
|
+
};
|
|
84
121
|
for (const r of resources) {
|
|
85
|
-
if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind))
|
|
86
|
-
continue;
|
|
87
122
|
if (isForeign(r))
|
|
88
123
|
continue;
|
|
89
|
-
|
|
90
|
-
? registry.expandedFieldMapForResource(r, aliases, aliasesByModule)
|
|
91
|
-
: registry.getFieldMapForKind(r.kind, aliases);
|
|
92
|
-
if (!fieldMap)
|
|
124
|
+
if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind))
|
|
93
125
|
continue;
|
|
94
|
-
|
|
95
|
-
if (!isRefEntry(entry))
|
|
96
|
-
continue;
|
|
97
|
-
descend(r, fieldPath.split("."), resolveTarget);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
/** Walks `obj` along `fieldPath` parts (dot notation with `[]` for arrays and `{}` for
|
|
102
|
-
* additionalProperties-typed maps) and replaces any `!ref` sentinel at the terminal slot
|
|
103
|
-
* with its resolved `{kind, name, alias?}`. Mutates the parent container in place. */
|
|
104
|
-
function descend(obj, parts, resolve) {
|
|
105
|
-
if (obj == null || typeof obj !== "object" || parts.length === 0)
|
|
106
|
-
return;
|
|
107
|
-
const [head, ...rest] = parts;
|
|
108
|
-
if (head === "{}") {
|
|
109
|
-
const container = obj;
|
|
110
|
-
for (const key of Object.keys(container)) {
|
|
111
|
-
const child = container[key];
|
|
112
|
-
if (rest.length === 0) {
|
|
113
|
-
if (isRefSentinel(child)) {
|
|
114
|
-
const target = resolve(child.source);
|
|
115
|
-
if (target)
|
|
116
|
-
container[key] = target;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
else {
|
|
120
|
-
descend(child, rest, resolve);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
const isArr = head.endsWith("[]");
|
|
126
|
-
const key = isArr ? head.slice(0, -2) : head;
|
|
127
|
-
const container = obj;
|
|
128
|
-
const val = container[key];
|
|
129
|
-
if (val == null)
|
|
130
|
-
return;
|
|
131
|
-
if (isArr) {
|
|
132
|
-
if (!Array.isArray(val))
|
|
133
|
-
return;
|
|
134
|
-
for (let i = 0; i < val.length; i++) {
|
|
135
|
-
if (rest.length === 0) {
|
|
136
|
-
const elem = val[i];
|
|
137
|
-
if (isRefSentinel(elem)) {
|
|
138
|
-
const target = resolve(elem.source);
|
|
139
|
-
if (target)
|
|
140
|
-
val[i] = target;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
else {
|
|
144
|
-
descend(val[i], rest, resolve);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
else {
|
|
149
|
-
if (rest.length === 0) {
|
|
150
|
-
if (isRefSentinel(val)) {
|
|
151
|
-
const target = resolve(val.source);
|
|
152
|
-
if (target)
|
|
153
|
-
container[key] = target;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
else {
|
|
157
|
-
descend(val, rest, resolve);
|
|
158
|
-
}
|
|
126
|
+
walk(r);
|
|
159
127
|
}
|
|
160
128
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema-compat.d.ts","sourceRoot":"","sources":["../src/schema-compat.ts"],"names":[],"mappings":"AAIA,QAAA,MAAM,GAAG,KAA0C,CAAC;AAEpD;;;;;;;mCAOmC;AACnC,wBAAgB,SAAS,IAAI,YAAY,CAAC,OAAO,GAAG,CAAC,CAOpD;AAKD,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,OAAO,CAAC;IACpB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;oEAEoE;AACpE,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC1B,mBAAmB,CAIrB;AAiDD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAelD;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,GAAG,EAAE,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,CAGxE;AAuBD,mFAAmF;AACnF,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAC;CACd;
|
|
1
|
+
{"version":3,"file":"schema-compat.d.ts","sourceRoot":"","sources":["../src/schema-compat.ts"],"names":[],"mappings":"AAIA,QAAA,MAAM,GAAG,KAA0C,CAAC;AAEpD;;;;;;;mCAOmC;AACnC,wBAAgB,SAAS,IAAI,YAAY,CAAC,OAAO,GAAG,CAAC,CAOpD;AAKD,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,OAAO,CAAC;IACpB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;oEAEoE;AACpE,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC1B,mBAAmB,CAIrB;AAiDD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAelD;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,GAAG,EAAE,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,CAGxE;AAuBD,mFAAmF;AACnF,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAC;CACd;AAaD,0GAA0G;AAC1G,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,WAAW,EAAE,CA2B/F;AAED;qFACqF;AACrF,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAQ7E;AAED;;;;6DAI6D;AAC7D,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,IAAI,EAAE,MAAM,GACX,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAsBjC;AAED;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAGnD,CAAC;AAEF,wEAAwE;AACxE,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAGzF;AAED,8DAA8D;AAC9D,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,GAAG,MAAM,CAyBnF;AAED,wFAAwF;AACxF,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,OAAO,CAiChG;AAED,6EAA6E;AAC7E,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,OAAO,CAiB5E;AAID,0EAA0E;AAC1E,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAOtG;AAED,gGAAgG;AAChG,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAUlF;AAED;iGACiG;AACjG,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,OAAO,EACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC/B,OAAO,CAqCT"}
|
package/dist/schema-compat.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import AjvModule from "ajv";
|
|
2
2
|
import addFormats from "ajv-formats";
|
|
3
|
-
import { isRefSentinel, isTaggedSentinel, ManifestRootSchema } from "@telorun/templating";
|
|
3
|
+
import { isRefSentinel, isTaggedSentinel, ManifestRootSchema, normalizeRefSlots } from "@telorun/templating";
|
|
4
4
|
const Ajv = AjvModule.default ?? AjvModule;
|
|
5
5
|
/** Creates a configured AJV instance (allErrors, strict: false, with formats).
|
|
6
6
|
* Also registers the kernel manifest root schema under `telo://manifest` so
|
|
@@ -100,21 +100,41 @@ function ajvErrorToPath(err) {
|
|
|
100
100
|
}
|
|
101
101
|
return result;
|
|
102
102
|
}
|
|
103
|
+
/** Does `schema` compile as-authored? Used to tell a malformed module schema
|
|
104
|
+
* (the author's problem) apart from a fault we introduced while normalizing it. */
|
|
105
|
+
function schemaCompiles(schema) {
|
|
106
|
+
try {
|
|
107
|
+
ajv.compile(schema);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
103
114
|
/** Validate actual data against a JSON Schema. Returns issues with path info, or empty array if valid. */
|
|
104
115
|
export function validateAgainstSchema(data, schema) {
|
|
105
116
|
let validate = compiledSchemaValidators.get(schema);
|
|
106
117
|
if (!validate) {
|
|
118
|
+
// Normalize outside the try: a fault in our own ref-slot normalization must
|
|
119
|
+
// surface, never be mistaken for the module author's schema being malformed.
|
|
120
|
+
// Drop the legacy scalar `type` an older published module may still pin on
|
|
121
|
+
// its `x-telo-ref` slots so a resolved reference object validates.
|
|
122
|
+
const normalized = normalizeRefSlots(schema);
|
|
107
123
|
try {
|
|
108
|
-
validate = ajv.compile(
|
|
109
|
-
compiledSchemaValidators.set(schema, validate);
|
|
124
|
+
validate = ajv.compile(normalized);
|
|
110
125
|
}
|
|
111
|
-
catch {
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
126
|
+
catch (err) {
|
|
127
|
+
// The normalized schema didn't compile. If the original schema is itself
|
|
128
|
+
// malformed, that is the module author's error — already surfaced once,
|
|
129
|
+
// anchored on the definition, by the analyzer's `SCHEMA_COMPILE_ERROR`
|
|
130
|
+
// pre-check (`DefinitionRegistry.schemaCompileError`); re-reporting it per
|
|
131
|
+
// resource would be noise, so skip. If the original compiles and only the
|
|
132
|
+
// normalized form fails, the fault is ours — let it throw.
|
|
133
|
+
if (schemaCompiles(schema))
|
|
134
|
+
throw err;
|
|
116
135
|
return [];
|
|
117
136
|
}
|
|
137
|
+
compiledSchemaValidators.set(schema, validate);
|
|
118
138
|
}
|
|
119
139
|
if (validate(data))
|
|
120
140
|
return [];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validate-cel-context.d.ts","sourceRoot":"","sources":["../src/validate-cel-context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,0BAA0B,EAAE,MAAM,qBAAqB,CAAC;AAEtF,MAAM,WAAW,kBAAkB;IACjC;mEAC+D;IAC/D,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACnC;;kDAE8C;IAC9C,IAAI,CAAC,EAAE;QACL,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAAC;KACxD,CAAC;IACF,OAAO,CAAC,EAAE;QACR,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;KAC/C,CAAC;IACF,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CAAC;CACtC;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,OAAO,EACd,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GAClC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,
|
|
1
|
+
{"version":3,"file":"validate-cel-context.d.ts","sourceRoot":"","sources":["../src/validate-cel-context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,0BAA0B,EAAE,MAAM,qBAAqB,CAAC;AAEtF,MAAM,WAAW,kBAAkB;IACjC;mEAC+D;IAC/D,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACnC;;kDAE8C;IAC9C,IAAI,CAAC,EAAE;QACL,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAAC;KACxD,CAAC;IACF,OAAO,CAAC,EAAE;QACR,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;KAC/C,CAAC;IACF,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CAAC;CACtC;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,OAAO,EACd,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GAClC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAkCjC;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAoBzE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EACjC,IAAI,CAAC,EAAE,kBAAkB,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GAChD,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAqGrB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC5B,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAQrB;AAWD;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,IAAI,SAAM,GACT,KAAK,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CAAE,CAAC,CAGvD"}
|
|
@@ -27,6 +27,11 @@ export function resolveTypeFieldToSchema(value, allManifests) {
|
|
|
27
27
|
if (obj.type || obj.properties) {
|
|
28
28
|
return obj;
|
|
29
29
|
}
|
|
30
|
+
// Named type reference resolved from a `!ref` → { kind, name } — resolve the
|
|
31
|
+
// named Telo.Type the same way as the bare-string form.
|
|
32
|
+
if (typeof obj.name === "string") {
|
|
33
|
+
return resolveTypeFieldToSchema(obj.name, allManifests);
|
|
34
|
+
}
|
|
30
35
|
}
|
|
31
36
|
return undefined;
|
|
32
37
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
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 { type AnalysisDiagnostic } from "./types.js";
|
|
5
|
+
/**
|
|
6
|
+
* Reference-form validation — the single enforcement point for "a reference is
|
|
7
|
+
* written `!ref <name>` (or `!ref <Alias>.<name>`), nothing else".
|
|
8
|
+
*
|
|
9
|
+
* Runs on the RAW manifest set, BEFORE inline-resource extraction and `!ref`
|
|
10
|
+
* sentinel resolution. That ordering is load-bearing: only at this point is an
|
|
11
|
+
* author-written value still distinguishable from the resolver's own
|
|
12
|
+
* substitution. After normalization both an author's `{kind, name}` and a
|
|
13
|
+
* resolved `!ref` are the same `{kind, name}` object, so no later pass — and no
|
|
14
|
+
* JSON Schema — can tell them apart.
|
|
15
|
+
*
|
|
16
|
+
* At every `x-telo-ref` slot the only accepted value is:
|
|
17
|
+
* - a `!ref` sentinel (or any tagged sentinel — e.g. a `${{ }}` ref passed
|
|
18
|
+
* through a template), or
|
|
19
|
+
* - an inline definition: a plain object with a `kind` and NO `name` (the
|
|
20
|
+
* extractor assigns the name), or
|
|
21
|
+
* - a `${{ }}` CEL expression string (a reference flowed through CEL).
|
|
22
|
+
*
|
|
23
|
+
* Rejected, each with an actionable diagnostic pointing at `!ref`:
|
|
24
|
+
* - the object form `{ kind, name }` (the old reference object), and
|
|
25
|
+
* - a bare string (the old name / dotted-FQN reference).
|
|
26
|
+
*/
|
|
27
|
+
export declare function validateReferenceForms(resources: ResourceManifest[], registry: DefinitionRegistry, aliases?: AliasResolver, aliasesByModule?: Map<string, AliasResolver>): AnalysisDiagnostic[];
|
|
28
|
+
//# sourceMappingURL=validate-reference-forms.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate-reference-forms.d.ts","sourceRoot":"","sources":["../src/validate-reference-forms.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAGnE,OAAO,EAAsB,KAAK,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAIzE;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,kBAAkB,EAC5B,OAAO,CAAC,EAAE,aAAa,EACvB,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,GAC3C,kBAAkB,EAAE,CA+DtB"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { isTaggedSentinel } from "@telorun/templating";
|
|
2
|
+
import { visitManifest } from "./manifest-visitor.js";
|
|
3
|
+
import { REF_VALIDATION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
|
|
4
|
+
import { DiagnosticSeverity } from "./types.js";
|
|
5
|
+
const SOURCE = "telo-analyzer";
|
|
6
|
+
/**
|
|
7
|
+
* Reference-form validation — the single enforcement point for "a reference is
|
|
8
|
+
* written `!ref <name>` (or `!ref <Alias>.<name>`), nothing else".
|
|
9
|
+
*
|
|
10
|
+
* Runs on the RAW manifest set, BEFORE inline-resource extraction and `!ref`
|
|
11
|
+
* sentinel resolution. That ordering is load-bearing: only at this point is an
|
|
12
|
+
* author-written value still distinguishable from the resolver's own
|
|
13
|
+
* substitution. After normalization both an author's `{kind, name}` and a
|
|
14
|
+
* resolved `!ref` are the same `{kind, name}` object, so no later pass — and no
|
|
15
|
+
* JSON Schema — can tell them apart.
|
|
16
|
+
*
|
|
17
|
+
* At every `x-telo-ref` slot the only accepted value is:
|
|
18
|
+
* - a `!ref` sentinel (or any tagged sentinel — e.g. a `${{ }}` ref passed
|
|
19
|
+
* through a template), or
|
|
20
|
+
* - an inline definition: a plain object with a `kind` and NO `name` (the
|
|
21
|
+
* extractor assigns the name), or
|
|
22
|
+
* - a `${{ }}` CEL expression string (a reference flowed through CEL).
|
|
23
|
+
*
|
|
24
|
+
* Rejected, each with an actionable diagnostic pointing at `!ref`:
|
|
25
|
+
* - the object form `{ kind, name }` (the old reference object), and
|
|
26
|
+
* - a bare string (the old name / dotted-FQN reference).
|
|
27
|
+
*/
|
|
28
|
+
export function validateReferenceForms(resources, registry, aliases, aliasesByModule) {
|
|
29
|
+
if (!aliases)
|
|
30
|
+
return [];
|
|
31
|
+
const diagnostics = [];
|
|
32
|
+
const isForeign = (r) => r.metadata?.forwardedExport === true;
|
|
33
|
+
const localResources = resources.filter((r) => !isForeign(r));
|
|
34
|
+
visitManifest(localResources, registry, {
|
|
35
|
+
onRef: (e) => {
|
|
36
|
+
const value = e.value;
|
|
37
|
+
// `!ref` and `!cel`/`${{ }}` sentinels are the supported shapes.
|
|
38
|
+
if (isTaggedSentinel(value))
|
|
39
|
+
return;
|
|
40
|
+
const r = e.source;
|
|
41
|
+
const resourceLabel = `${r.kind}/${r.metadata.name}`;
|
|
42
|
+
const resourceData = { kind: r.kind, name: r.metadata.name };
|
|
43
|
+
const filePath = r.metadata?.source;
|
|
44
|
+
const path = e.concretePath;
|
|
45
|
+
if (typeof value === "string") {
|
|
46
|
+
// A `${{ }}` reference flowed through CEL is fine; any other bare
|
|
47
|
+
// string at a ref slot is the removed string / dotted-FQN form.
|
|
48
|
+
if (value.includes("${{"))
|
|
49
|
+
return;
|
|
50
|
+
diagnostics.push({
|
|
51
|
+
severity: DiagnosticSeverity.Error,
|
|
52
|
+
code: "INVALID_REFERENCE_FORM",
|
|
53
|
+
source: SOURCE,
|
|
54
|
+
message: `${resourceLabel}: string reference at '${path}' → '${value}' is not supported; write it as '!ref ${refHint(value)}'`,
|
|
55
|
+
data: { resource: resourceData, filePath, path },
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
60
|
+
const obj = value;
|
|
61
|
+
// A plain object is an inline definition unless it names a resource —
|
|
62
|
+
// a `name` makes it the removed `{ kind, name }` reference object.
|
|
63
|
+
if (typeof obj.name === "string" && typeof obj.kind === "string") {
|
|
64
|
+
diagnostics.push({
|
|
65
|
+
severity: DiagnosticSeverity.Error,
|
|
66
|
+
code: "INVALID_REFERENCE_FORM",
|
|
67
|
+
source: SOURCE,
|
|
68
|
+
message: `${resourceLabel}: object reference '{ kind, name }' at '${path}' is not supported; write it as '!ref ${obj.name}'`,
|
|
69
|
+
data: { resource: resourceData, filePath, path },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
}, {
|
|
75
|
+
aliases,
|
|
76
|
+
aliasesByModule,
|
|
77
|
+
skipKinds: SYSTEM_KINDS,
|
|
78
|
+
expand: true,
|
|
79
|
+
discoverNestedRefs: true,
|
|
80
|
+
});
|
|
81
|
+
return diagnostics;
|
|
82
|
+
}
|
|
83
|
+
/** Best-effort name for the `!ref` suggestion in a string-ref diagnostic: a
|
|
84
|
+
* dotted-FQN (`Http.Api.UsersApi`) keeps its last segment, an alias-qualified
|
|
85
|
+
* name (`Console.writeLine`) is left intact, a bare name passes through. */
|
|
86
|
+
function refHint(value) {
|
|
87
|
+
const dotCount = (value.match(/\./g) ?? []).length;
|
|
88
|
+
if (dotCount >= 2)
|
|
89
|
+
return value.slice(value.lastIndexOf(".") + 1);
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validate-references.d.ts","sourceRoot":"","sources":["../src/validate-references.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAMrD,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA4C/F;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,EAAE,eAAe,GACvB,kBAAkB,EAAE,
|
|
1
|
+
{"version":3,"file":"validate-references.d.ts","sourceRoot":"","sources":["../src/validate-references.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAMrD,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA4C/F;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,EAAE,eAAe,GACvB,kBAAkB,EAAE,CA6ctB"}
|
|
@@ -272,43 +272,10 @@ export function validateReferences(resources, context) {
|
|
|
272
272
|
}
|
|
273
273
|
return;
|
|
274
274
|
}
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
const lastDot = val.lastIndexOf(".");
|
|
280
|
-
const refName = lastDot > 0 ? val.slice(lastDot + 1) : val;
|
|
281
|
-
const refKindPrefix = lastDot > 0 ? val.slice(0, lastDot) : undefined;
|
|
282
|
-
const target = byName.get(refName) ?? visibleScopeManifests.find((m) => m.metadata?.name === refName);
|
|
283
|
-
if (!target) {
|
|
284
|
-
// Cross-module reference: "Alias.ResourceName" (single dot, bare alias prefix).
|
|
285
|
-
// The resource lives in the imported module's scope and can't be validated here.
|
|
286
|
-
// Multi-dot prefixes like "Alias.Kind.Name" are local resources with qualified
|
|
287
|
-
// kinds — those must be validated.
|
|
288
|
-
if (refKindPrefix && !refKindPrefix.includes(".") && aliases.hasAlias(refKindPrefix)) {
|
|
289
|
-
return;
|
|
290
|
-
}
|
|
291
|
-
diagnostics.push({
|
|
292
|
-
severity: DiagnosticSeverity.Error,
|
|
293
|
-
code: "UNRESOLVED_REFERENCE",
|
|
294
|
-
source: SOURCE,
|
|
295
|
-
message: `${resourceLabel}: reference at '${concretePath}' → resource '${val}' not found`,
|
|
296
|
-
data: { resource: resourceData, filePath, path: concretePath },
|
|
297
|
-
});
|
|
298
|
-
return;
|
|
299
|
-
}
|
|
300
|
-
const kindErrors = checkKind(target.kind, entry, registry, aliases);
|
|
301
|
-
if (kindErrors.length > 0) {
|
|
302
|
-
diagnostics.push({
|
|
303
|
-
severity: DiagnosticSeverity.Error,
|
|
304
|
-
code: "REFERENCE_KIND_MISMATCH",
|
|
305
|
-
source: SOURCE,
|
|
306
|
-
message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
|
|
307
|
-
data: { resource: resourceData, filePath, path: concretePath },
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
275
|
+
// Bare strings are no longer a reference shape — `validateReferenceForms`
|
|
276
|
+
// rejects an author-written string at a ref slot before this pass runs,
|
|
277
|
+
// and a `${{ }}` reference flowed through CEL is resolved/typed
|
|
278
|
+
// elsewhere. Anything still a string here is not a reference to resolve.
|
|
312
279
|
if (typeof val !== "object")
|
|
313
280
|
return;
|
|
314
281
|
const refVal = val;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/analyzer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Telo Analyzer - Static manifest validator for Telo manifests.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -37,18 +37,18 @@
|
|
|
37
37
|
"src/**"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@marcbachmann/cel-js": "^7.
|
|
40
|
+
"@marcbachmann/cel-js": "^7.6.1",
|
|
41
41
|
"ajv": "^8.17.1",
|
|
42
42
|
"ajv-formats": "^3.0.1",
|
|
43
43
|
"jsonpath-plus": "^10.3.0",
|
|
44
44
|
"yaml": "^2.8.3",
|
|
45
|
-
"@telorun/templating": "0.
|
|
45
|
+
"@telorun/templating": "0.8.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/node": "^20.0.0",
|
|
49
49
|
"typescript": "^5.0.0",
|
|
50
50
|
"vitest": "^2.1.8",
|
|
51
|
-
"@telorun/sdk": "0.
|
|
51
|
+
"@telorun/sdk": "0.23.0"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"@telorun/sdk": "*"
|
package/src/analyzer.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { validateExtends } from "./validate-extends.js";
|
|
|
36
36
|
import { validateNestedInlineResources } from "./validate-nested-inline.js";
|
|
37
37
|
import { validateProviderCoherence } from "./validate-provider-coherence.js";
|
|
38
38
|
import { validateReferences } from "./validate-references.js";
|
|
39
|
+
import { validateReferenceForms } from "./validate-reference-forms.js";
|
|
39
40
|
import { validateUnusedDeclarations } from "./validate-unused-declarations.js";
|
|
40
41
|
import { validateThrowsCoverage } from "./validate-throws-coverage.js";
|
|
41
42
|
|
|
@@ -774,6 +775,15 @@ export class StaticAnalyzer {
|
|
|
774
775
|
defs.register(normalized);
|
|
775
776
|
}
|
|
776
777
|
|
|
778
|
+
// Reference-form validation — enforce `!ref` as the only reference shape.
|
|
779
|
+
// Runs on the RAW manifests, BEFORE inline extraction and sentinel
|
|
780
|
+
// resolution, while an author-written `{kind, name}` is still
|
|
781
|
+
// distinguishable from the resolver's own substitution (after Phase 2/2.5
|
|
782
|
+
// they are the same object).
|
|
783
|
+
if (!options?.skipValidation) {
|
|
784
|
+
diagnostics.push(...validateReferenceForms(manifests, defs, aliases, aliasesByModule));
|
|
785
|
+
}
|
|
786
|
+
|
|
777
787
|
// Phase 2: extract inline resources from x-telo-ref slots into first-class manifests
|
|
778
788
|
const allManifests = normalizeInlineResources(manifests, defs, aliases, aliasesByModule);
|
|
779
789
|
|
|
@@ -781,7 +791,7 @@ export class StaticAnalyzer {
|
|
|
781
791
|
// {kind, name} objects so downstream phases (validation, dependency graph,
|
|
782
792
|
// kernel controllers) see a uniform shape. Runs after normalize so both
|
|
783
793
|
// original and inline-extracted manifests have their sentinels resolved.
|
|
784
|
-
resolveRefSentinels(allManifests,
|
|
794
|
+
resolveRefSentinels(allManifests, aliases, aliasesByModule);
|
|
785
795
|
|
|
786
796
|
// Trusted-input fast path: when the caller has already attested that
|
|
787
797
|
// this exact manifest set passes analysis (e.g. via the kernel's
|
|
@@ -1286,13 +1296,7 @@ export class StaticAnalyzer {
|
|
|
1286
1296
|
// Resolve !ref sentinels after normalize so both the original and
|
|
1287
1297
|
// inline-extracted manifests get their refs canonicalized to
|
|
1288
1298
|
// {kind, name} for the kernel that consumes this output.
|
|
1289
|
-
resolveRefSentinels(
|
|
1290
|
-
normalized,
|
|
1291
|
-
ctx.definitions!,
|
|
1292
|
-
ctx.aliases,
|
|
1293
|
-
ctx.aliasesByModule,
|
|
1294
|
-
crossModuleTargets ?? [],
|
|
1295
|
-
);
|
|
1299
|
+
resolveRefSentinels(normalized, ctx.aliases, ctx.aliasesByModule, crossModuleTargets ?? []);
|
|
1296
1300
|
return normalized;
|
|
1297
1301
|
}
|
|
1298
1302
|
|
package/src/builtins.ts
CHANGED
|
@@ -236,7 +236,7 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
|
|
|
236
236
|
},
|
|
237
237
|
// Gated reference: run() a Runnable/Service only when the
|
|
238
238
|
// `when` CEL guard holds. Discriminated by the `ref` key. `ref`
|
|
239
|
-
// is a
|
|
239
|
+
// is a `!ref` that resolves to the `{ kind, name }` shape below.
|
|
240
240
|
{
|
|
241
241
|
type: "object",
|
|
242
242
|
required: ["ref"],
|
|
@@ -264,12 +264,11 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
|
|
|
264
264
|
// with an optional `name` (for steps.<name>.result plumbing),
|
|
265
265
|
// `when` guard, and `inputs`. Discriminated by the `invoke` key.
|
|
266
266
|
// Control flow (if/while/switch/try) is not available here —
|
|
267
|
-
// reach for Run.Sequence. `invoke` is ref-only
|
|
268
|
-
// to
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
// Run.Sequence invoke steps.
|
|
267
|
+
// reach for Run.Sequence. `invoke` is ref-only: a `!ref` that
|
|
268
|
+
// resolves to the `{ kind, name }` shape below. Requiring `name`
|
|
269
|
+
// rejects an inline `{ kind }` definition (no name) at analysis
|
|
270
|
+
// instead of failing at boot with an undefined resource name. The
|
|
271
|
+
// Invocable/Runnable kind set mirrors Run.Sequence invoke steps.
|
|
273
272
|
{
|
|
274
273
|
type: "object",
|
|
275
274
|
required: ["invoke"],
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
-
import { isRefSentinel } from "@telorun/templating";
|
|
2
|
+
import { isRefSentinel, isTaggedSentinel } from "@telorun/templating";
|
|
3
3
|
import type { AliasResolver } from "./alias-resolver.js";
|
|
4
|
-
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
5
|
-
import { isRefEntry } from "./reference-field-map.js";
|
|
6
4
|
import { REF_RESOLUTION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
|
|
7
5
|
|
|
8
6
|
/** Resolved ref shape written in place of a `!ref` sentinel. `alias` is set only for
|
|
@@ -10,9 +8,23 @@ import { REF_RESOLUTION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
|
|
|
10
8
|
type ResolvedRef = { kind: string; name: string; alias?: string };
|
|
11
9
|
|
|
12
10
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* Rewrites every `!ref <name>` sentinel in each non-system resource's value tree
|
|
12
|
+
* to `{kind, name}` (local) or `{kind, name, alias}` (cross-module), in place.
|
|
13
|
+
*
|
|
14
|
+
* The walk is value-tree-driven, not field-map-driven: a `!ref` tag is an
|
|
15
|
+
* *explicit* reference marker, so any sentinel found anywhere is unambiguously a
|
|
16
|
+
* reference and is resolved. This reaches sites the field map intentionally does
|
|
17
|
+
* not descend — notably `Run.Sequence` step `invoke`s (behind a local `$ref`)
|
|
18
|
+
* and references nested inside inline definitions — so every downstream consumer
|
|
19
|
+
* (Phase-5 injection, the runtime controllers, the analyzer's step-context and
|
|
20
|
+
* dependency passes) sees the uniform `{kind, name}` shape regardless of where
|
|
21
|
+
* the reference was written.
|
|
22
|
+
*
|
|
23
|
+
* Resolving a sentinel here does NOT cause Phase-5 injection: that pass is
|
|
24
|
+
* driven by the field map, which still excludes step `invoke`s, so a resolved
|
|
25
|
+
* step invoke stays `{kind, name}` and is dispatched through
|
|
26
|
+
* `executeInvokeStep` (preserving `<Kind>.<Name>.Invoked` events) rather than
|
|
27
|
+
* being replaced with a live instance.
|
|
16
28
|
*
|
|
17
29
|
* Reference grammar — the tag's source string is split on the FIRST dot:
|
|
18
30
|
* - `!ref writeLine` → local resource `writeLine`
|
|
@@ -22,8 +34,10 @@ type ResolvedRef = { kind: string; name: string; alias?: string };
|
|
|
22
34
|
*
|
|
23
35
|
* Aliases are PascalCase identifiers without dots and resource names carry no dots
|
|
24
36
|
* (enforced as a hard diagnostic), so the first-dot split is unambiguous. When the
|
|
25
|
-
* name doesn't resolve
|
|
26
|
-
*
|
|
37
|
+
* name doesn't resolve (e.g. a scope-local target, or a cross-module reference in
|
|
38
|
+
* partial single-file analysis), the sentinel is left in place — the runtime
|
|
39
|
+
* resolves scope-local names on demand, and `validateReferences` emits the
|
|
40
|
+
* `UNRESOLVED_REFERENCE` diagnostic for genuine misses.
|
|
27
41
|
*
|
|
28
42
|
* Forwarded foreign resources (an imported library's exported instances, carrying a
|
|
29
43
|
* `metadata.module` that isn't a root module) are resolution TARGETS only — they are not
|
|
@@ -31,7 +45,6 @@ type ResolvedRef = { kind: string; name: string; alias?: string };
|
|
|
31
45
|
*/
|
|
32
46
|
export function resolveRefSentinels(
|
|
33
47
|
resources: ResourceManifest[],
|
|
34
|
-
registry: DefinitionRegistry,
|
|
35
48
|
aliases?: AliasResolver,
|
|
36
49
|
aliasesByModule?: Map<string, AliasResolver>,
|
|
37
50
|
// Extra foreign resources used only as cross-module resolution TARGETS (not mutated, not
|
|
@@ -96,77 +109,27 @@ export function resolveRefSentinels(
|
|
|
96
109
|
return undefined;
|
|
97
110
|
};
|
|
98
111
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
aliases && aliasesByModule
|
|
105
|
-
? registry.expandedFieldMapForResource(r, aliases, aliasesByModule)
|
|
106
|
-
: registry.getFieldMapForKind(r.kind, aliases);
|
|
107
|
-
if (!fieldMap) continue;
|
|
108
|
-
|
|
109
|
-
for (const [fieldPath, entry] of fieldMap) {
|
|
110
|
-
if (!isRefEntry(entry)) continue;
|
|
111
|
-
descend(r as Record<string, unknown>, fieldPath.split("."), resolveTarget);
|
|
112
|
+
// Resolve every `!ref` sentinel in the tree; leave opaque tagged / precompiled
|
|
113
|
+
// nodes (e.g. `!cel`) untouched and don't descend into them.
|
|
114
|
+
const walk = (value: unknown): unknown => {
|
|
115
|
+
if (isRefSentinel(value)) {
|
|
116
|
+
return resolveTarget(value.source) ?? value;
|
|
112
117
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
function descend(
|
|
120
|
-
obj: unknown,
|
|
121
|
-
parts: string[],
|
|
122
|
-
resolve: (source: string) => ResolvedRef | undefined,
|
|
123
|
-
): void {
|
|
124
|
-
if (obj == null || typeof obj !== "object" || parts.length === 0) return;
|
|
125
|
-
const [head, ...rest] = parts;
|
|
126
|
-
|
|
127
|
-
if (head === "{}") {
|
|
128
|
-
const container = obj as Record<string, unknown>;
|
|
129
|
-
for (const key of Object.keys(container)) {
|
|
130
|
-
const child = container[key];
|
|
131
|
-
if (rest.length === 0) {
|
|
132
|
-
if (isRefSentinel(child)) {
|
|
133
|
-
const target = resolve(child.source);
|
|
134
|
-
if (target) container[key] = target;
|
|
135
|
-
}
|
|
136
|
-
} else {
|
|
137
|
-
descend(child, rest, resolve);
|
|
138
|
-
}
|
|
118
|
+
if (value === null || typeof value !== "object") return value;
|
|
119
|
+
if (isTaggedSentinel(value)) return value;
|
|
120
|
+
if ((value as { __compiled?: unknown }).__compiled) return value;
|
|
121
|
+
if (Array.isArray(value)) {
|
|
122
|
+
for (let i = 0; i < value.length; i++) value[i] = walk(value[i]);
|
|
123
|
+
return value;
|
|
139
124
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const key = isArr ? head.slice(0, -2) : head;
|
|
145
|
-
const container = obj as Record<string, unknown>;
|
|
146
|
-
const val = container[key];
|
|
147
|
-
if (val == null) return;
|
|
125
|
+
const obj = value as Record<string, unknown>;
|
|
126
|
+
for (const key of Object.keys(obj)) obj[key] = walk(obj[key]);
|
|
127
|
+
return value;
|
|
128
|
+
};
|
|
148
129
|
|
|
149
|
-
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const elem = val[i];
|
|
154
|
-
if (isRefSentinel(elem)) {
|
|
155
|
-
const target = resolve(elem.source);
|
|
156
|
-
if (target) val[i] = target;
|
|
157
|
-
}
|
|
158
|
-
} else {
|
|
159
|
-
descend(val[i], rest, resolve);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
} else {
|
|
163
|
-
if (rest.length === 0) {
|
|
164
|
-
if (isRefSentinel(val)) {
|
|
165
|
-
const target = resolve(val.source);
|
|
166
|
-
if (target) container[key] = target;
|
|
167
|
-
}
|
|
168
|
-
} else {
|
|
169
|
-
descend(val, rest, resolve);
|
|
170
|
-
}
|
|
130
|
+
for (const r of resources) {
|
|
131
|
+
if (isForeign(r)) continue;
|
|
132
|
+
if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
|
|
133
|
+
walk(r as Record<string, unknown>);
|
|
171
134
|
}
|
|
172
135
|
}
|
package/src/schema-compat.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import AjvModule from "ajv";
|
|
2
2
|
import addFormats from "ajv-formats";
|
|
3
|
-
import { isRefSentinel, isTaggedSentinel, ManifestRootSchema } from "@telorun/templating";
|
|
3
|
+
import { isRefSentinel, isTaggedSentinel, ManifestRootSchema, normalizeRefSlots } from "@telorun/templating";
|
|
4
4
|
|
|
5
5
|
const Ajv = (AjvModule as any).default ?? AjvModule;
|
|
6
6
|
|
|
@@ -138,20 +138,39 @@ export interface SchemaIssue {
|
|
|
138
138
|
path: string;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/** Does `schema` compile as-authored? Used to tell a malformed module schema
|
|
142
|
+
* (the author's problem) apart from a fault we introduced while normalizing it. */
|
|
143
|
+
function schemaCompiles(schema: Record<string, any>): boolean {
|
|
144
|
+
try {
|
|
145
|
+
ajv.compile(schema);
|
|
146
|
+
return true;
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
141
152
|
/** Validate actual data against a JSON Schema. Returns issues with path info, or empty array if valid. */
|
|
142
153
|
export function validateAgainstSchema(data: unknown, schema: Record<string, any>): SchemaIssue[] {
|
|
143
154
|
let validate = compiledSchemaValidators.get(schema);
|
|
144
155
|
if (!validate) {
|
|
156
|
+
// Normalize outside the try: a fault in our own ref-slot normalization must
|
|
157
|
+
// surface, never be mistaken for the module author's schema being malformed.
|
|
158
|
+
// Drop the legacy scalar `type` an older published module may still pin on
|
|
159
|
+
// its `x-telo-ref` slots so a resolved reference object validates.
|
|
160
|
+
const normalized = normalizeRefSlots(schema) as Record<string, any>;
|
|
145
161
|
try {
|
|
146
|
-
validate = ajv.compile(
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
//
|
|
150
|
-
// definition
|
|
151
|
-
// (`DefinitionRegistry.schemaCompileError`)
|
|
152
|
-
//
|
|
162
|
+
validate = ajv.compile(normalized);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// The normalized schema didn't compile. If the original schema is itself
|
|
165
|
+
// malformed, that is the module author's error — already surfaced once,
|
|
166
|
+
// anchored on the definition, by the analyzer's `SCHEMA_COMPILE_ERROR`
|
|
167
|
+
// pre-check (`DefinitionRegistry.schemaCompileError`); re-reporting it per
|
|
168
|
+
// resource would be noise, so skip. If the original compiles and only the
|
|
169
|
+
// normalized form fails, the fault is ours — let it throw.
|
|
170
|
+
if (schemaCompiles(schema)) throw err;
|
|
153
171
|
return [];
|
|
154
172
|
}
|
|
173
|
+
compiledSchemaValidators.set(schema, validate);
|
|
155
174
|
}
|
|
156
175
|
if (validate(data)) return [];
|
|
157
176
|
return (validate.errors ?? []).map((err: any) => ({
|
|
@@ -51,6 +51,11 @@ export function resolveTypeFieldToSchema(
|
|
|
51
51
|
if (obj.type || obj.properties) {
|
|
52
52
|
return obj;
|
|
53
53
|
}
|
|
54
|
+
// Named type reference resolved from a `!ref` → { kind, name } — resolve the
|
|
55
|
+
// named Telo.Type the same way as the bare-string form.
|
|
56
|
+
if (typeof obj.name === "string") {
|
|
57
|
+
return resolveTypeFieldToSchema(obj.name, allManifests);
|
|
58
|
+
}
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
return undefined;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { isTaggedSentinel } from "@telorun/templating";
|
|
3
|
+
import type { AliasResolver } from "./alias-resolver.js";
|
|
4
|
+
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
5
|
+
import { visitManifest } from "./manifest-visitor.js";
|
|
6
|
+
import { REF_VALIDATION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
|
|
7
|
+
import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
|
|
8
|
+
|
|
9
|
+
const SOURCE = "telo-analyzer";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Reference-form validation — the single enforcement point for "a reference is
|
|
13
|
+
* written `!ref <name>` (or `!ref <Alias>.<name>`), nothing else".
|
|
14
|
+
*
|
|
15
|
+
* Runs on the RAW manifest set, BEFORE inline-resource extraction and `!ref`
|
|
16
|
+
* sentinel resolution. That ordering is load-bearing: only at this point is an
|
|
17
|
+
* author-written value still distinguishable from the resolver's own
|
|
18
|
+
* substitution. After normalization both an author's `{kind, name}` and a
|
|
19
|
+
* resolved `!ref` are the same `{kind, name}` object, so no later pass — and no
|
|
20
|
+
* JSON Schema — can tell them apart.
|
|
21
|
+
*
|
|
22
|
+
* At every `x-telo-ref` slot the only accepted value is:
|
|
23
|
+
* - a `!ref` sentinel (or any tagged sentinel — e.g. a `${{ }}` ref passed
|
|
24
|
+
* through a template), or
|
|
25
|
+
* - an inline definition: a plain object with a `kind` and NO `name` (the
|
|
26
|
+
* extractor assigns the name), or
|
|
27
|
+
* - a `${{ }}` CEL expression string (a reference flowed through CEL).
|
|
28
|
+
*
|
|
29
|
+
* Rejected, each with an actionable diagnostic pointing at `!ref`:
|
|
30
|
+
* - the object form `{ kind, name }` (the old reference object), and
|
|
31
|
+
* - a bare string (the old name / dotted-FQN reference).
|
|
32
|
+
*/
|
|
33
|
+
export function validateReferenceForms(
|
|
34
|
+
resources: ResourceManifest[],
|
|
35
|
+
registry: DefinitionRegistry,
|
|
36
|
+
aliases?: AliasResolver,
|
|
37
|
+
aliasesByModule?: Map<string, AliasResolver>,
|
|
38
|
+
): AnalysisDiagnostic[] {
|
|
39
|
+
if (!aliases) return [];
|
|
40
|
+
const diagnostics: AnalysisDiagnostic[] = [];
|
|
41
|
+
|
|
42
|
+
const isForeign = (r: ResourceManifest): boolean =>
|
|
43
|
+
(r.metadata as { forwardedExport?: boolean } | undefined)?.forwardedExport === true;
|
|
44
|
+
const localResources = resources.filter((r) => !isForeign(r));
|
|
45
|
+
|
|
46
|
+
visitManifest(
|
|
47
|
+
localResources,
|
|
48
|
+
registry,
|
|
49
|
+
{
|
|
50
|
+
onRef: (e) => {
|
|
51
|
+
const value = e.value;
|
|
52
|
+
// `!ref` and `!cel`/`${{ }}` sentinels are the supported shapes.
|
|
53
|
+
if (isTaggedSentinel(value)) return;
|
|
54
|
+
|
|
55
|
+
const r = e.source;
|
|
56
|
+
const resourceLabel = `${r.kind}/${r.metadata!.name as string}`;
|
|
57
|
+
const resourceData = { kind: r.kind, name: r.metadata!.name as string };
|
|
58
|
+
const filePath = (r.metadata as { source?: string } | undefined)?.source;
|
|
59
|
+
const path = e.concretePath;
|
|
60
|
+
|
|
61
|
+
if (typeof value === "string") {
|
|
62
|
+
// A `${{ }}` reference flowed through CEL is fine; any other bare
|
|
63
|
+
// string at a ref slot is the removed string / dotted-FQN form.
|
|
64
|
+
if (value.includes("${{")) return;
|
|
65
|
+
diagnostics.push({
|
|
66
|
+
severity: DiagnosticSeverity.Error,
|
|
67
|
+
code: "INVALID_REFERENCE_FORM",
|
|
68
|
+
source: SOURCE,
|
|
69
|
+
message: `${resourceLabel}: string reference at '${path}' → '${value}' is not supported; write it as '!ref ${refHint(value)}'`,
|
|
70
|
+
data: { resource: resourceData, filePath, path },
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
76
|
+
const obj = value as Record<string, unknown>;
|
|
77
|
+
// A plain object is an inline definition unless it names a resource —
|
|
78
|
+
// a `name` makes it the removed `{ kind, name }` reference object.
|
|
79
|
+
if (typeof obj.name === "string" && typeof obj.kind === "string") {
|
|
80
|
+
diagnostics.push({
|
|
81
|
+
severity: DiagnosticSeverity.Error,
|
|
82
|
+
code: "INVALID_REFERENCE_FORM",
|
|
83
|
+
source: SOURCE,
|
|
84
|
+
message: `${resourceLabel}: object reference '{ kind, name }' at '${path}' is not supported; write it as '!ref ${obj.name}'`,
|
|
85
|
+
data: { resource: resourceData, filePath, path },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
aliases,
|
|
93
|
+
aliasesByModule,
|
|
94
|
+
skipKinds: SYSTEM_KINDS,
|
|
95
|
+
expand: true,
|
|
96
|
+
discoverNestedRefs: true,
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return diagnostics;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Best-effort name for the `!ref` suggestion in a string-ref diagnostic: a
|
|
104
|
+
* dotted-FQN (`Http.Api.UsersApi`) keeps its last segment, an alias-qualified
|
|
105
|
+
* name (`Console.writeLine`) is left intact, a bare name passes through. */
|
|
106
|
+
function refHint(value: string): string {
|
|
107
|
+
const dotCount = (value.match(/\./g) ?? []).length;
|
|
108
|
+
if (dotCount >= 2) return value.slice(value.lastIndexOf(".") + 1);
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
@@ -289,45 +289,10 @@ export function validateReferences(
|
|
|
289
289
|
return;
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
const lastDot = val.lastIndexOf(".");
|
|
297
|
-
const refName = lastDot > 0 ? val.slice(lastDot + 1) : val;
|
|
298
|
-
const refKindPrefix = lastDot > 0 ? val.slice(0, lastDot) : undefined;
|
|
299
|
-
const target =
|
|
300
|
-
byName.get(refName) ?? visibleScopeManifests.find((m) => m.metadata?.name === refName);
|
|
301
|
-
if (!target) {
|
|
302
|
-
// Cross-module reference: "Alias.ResourceName" (single dot, bare alias prefix).
|
|
303
|
-
// The resource lives in the imported module's scope and can't be validated here.
|
|
304
|
-
// Multi-dot prefixes like "Alias.Kind.Name" are local resources with qualified
|
|
305
|
-
// kinds — those must be validated.
|
|
306
|
-
if (refKindPrefix && !refKindPrefix.includes(".") && aliases.hasAlias(refKindPrefix)) {
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
diagnostics.push({
|
|
310
|
-
severity: DiagnosticSeverity.Error,
|
|
311
|
-
code: "UNRESOLVED_REFERENCE",
|
|
312
|
-
source: SOURCE,
|
|
313
|
-
message: `${resourceLabel}: reference at '${concretePath}' → resource '${val}' not found`,
|
|
314
|
-
data: { resource: resourceData, filePath, path: concretePath },
|
|
315
|
-
});
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
const kindErrors = checkKind(target.kind as string, entry, registry, aliases);
|
|
319
|
-
if (kindErrors.length > 0) {
|
|
320
|
-
diagnostics.push({
|
|
321
|
-
severity: DiagnosticSeverity.Error,
|
|
322
|
-
code: "REFERENCE_KIND_MISMATCH",
|
|
323
|
-
source: SOURCE,
|
|
324
|
-
message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
|
|
325
|
-
data: { resource: resourceData, filePath, path: concretePath },
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
|
-
return;
|
|
329
|
-
}
|
|
330
|
-
|
|
292
|
+
// Bare strings are no longer a reference shape — `validateReferenceForms`
|
|
293
|
+
// rejects an author-written string at a ref slot before this pass runs,
|
|
294
|
+
// and a `${{ }}` reference flowed through CEL is resolved/typed
|
|
295
|
+
// elsewhere. Anything still a string here is not a reference to resolve.
|
|
331
296
|
if (typeof val !== "object") return;
|
|
332
297
|
const refVal = val as Record<string, unknown>;
|
|
333
298
|
|