@telorun/analyzer 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +12 -1
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +14 -0
- package/dist/dependency-graph.d.ts.map +1 -1
- package/dist/dependency-graph.js +27 -13
- package/dist/position-metadata.d.ts +6 -1
- package/dist/position-metadata.d.ts.map +1 -1
- package/dist/position-metadata.js +10 -2
- package/dist/precompile.d.ts.map +1 -1
- package/dist/precompile.js +9 -1
- package/dist/reference-field-map.js +58 -6
- 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/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/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +32 -6
- package/package.json +2 -2
- package/src/analyzer.ts +13 -1
- package/src/builtins.ts +14 -0
- package/src/dependency-graph.ts +27 -14
- package/src/position-metadata.ts +10 -2
- package/src/precompile.ts +8 -1
- package/src/reference-field-map.ts +83 -6
- package/src/resolve-ref-sentinels.ts +127 -0
- package/src/schema-compat.ts +19 -2
- package/src/system-kinds.ts +37 -0
- package/src/validate-references.ts +34 -6
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;
|
|
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;AAe9B,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA+c/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,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IA6dvB,aAAa,CACX,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAMvB,SAAS,CAAC,SAAS,EAAE,gBAAgB,EAAE,EAAE,QAAQ,EAAE,gBAAgB,GAAG,gBAAgB,EAAE;IAexF,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
|
@@ -7,6 +7,7 @@ import { buildKernelGlobalsSchema, mergeKernelGlobalsIntoContext } from "./kerne
|
|
|
7
7
|
import { computeSuggestKind } from "./kind-suggest.js";
|
|
8
8
|
import { isModuleKind } from "./module-kinds.js";
|
|
9
9
|
import { normalizeInlineResources } from "./normalize-inline-resources.js";
|
|
10
|
+
import { resolveRefSentinels } from "./resolve-ref-sentinels.js";
|
|
10
11
|
import { rewriteSyntheticOrigins } from "./rewrite-synthetic-origins.js";
|
|
11
12
|
import { celTypeSatisfiesJsonSchema, substituteCelFields, validateAgainstSchema, } from "./schema-compat.js";
|
|
12
13
|
import { DiagnosticSeverity } from "./types.js";
|
|
@@ -492,6 +493,11 @@ export class StaticAnalyzer {
|
|
|
492
493
|
}
|
|
493
494
|
// Phase 2: extract inline resources from x-telo-ref slots into first-class manifests
|
|
494
495
|
const allManifests = normalizeInlineResources(manifests, defs, aliases, aliasesByModule);
|
|
496
|
+
// Phase 2.5: resolve `!ref <name>` sentinels at every ref slot to canonical
|
|
497
|
+
// {kind, name} objects so downstream phases (validation, dependency graph,
|
|
498
|
+
// kernel controllers) see a uniform shape. Runs after normalize so both
|
|
499
|
+
// original and inline-extracted manifests have their sentinels resolved.
|
|
500
|
+
resolveRefSentinels(allManifests, defs, aliases, aliasesByModule);
|
|
495
501
|
// Trusted-input fast path: when the caller has already attested that
|
|
496
502
|
// this exact manifest set passes analysis (e.g. via the kernel's
|
|
497
503
|
// hash-stamped `.validated.json` cache), skip the validation walk.
|
|
@@ -788,7 +794,12 @@ export class StaticAnalyzer {
|
|
|
788
794
|
}
|
|
789
795
|
normalize(manifests, registry) {
|
|
790
796
|
const ctx = registry._context();
|
|
791
|
-
|
|
797
|
+
const normalized = normalizeInlineResources(manifests, ctx.definitions, ctx.aliases, ctx.aliasesByModule);
|
|
798
|
+
// Resolve !ref sentinels after normalize so both the original and
|
|
799
|
+
// inline-extracted manifests get their refs canonicalized to
|
|
800
|
+
// {kind, name} for the kernel that consumes this output.
|
|
801
|
+
resolveRefSentinels(normalized, ctx.definitions, ctx.aliases, ctx.aliasesByModule);
|
|
802
|
+
return normalized;
|
|
792
803
|
}
|
|
793
804
|
prepare(manifests, registry) {
|
|
794
805
|
const ctx = registry._context();
|
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,EAiU/C,CAAC"}
|
package/dist/builtins.js
CHANGED
|
@@ -218,6 +218,20 @@ export const KERNEL_BUILTINS = [
|
|
|
218
218
|
anyOf: [
|
|
219
219
|
{ type: "string", "x-telo-ref": "telo#Runnable" },
|
|
220
220
|
{ type: "string", "x-telo-ref": "telo#Service" },
|
|
221
|
+
// Post-resolution shape that `resolveRefSentinels`
|
|
222
|
+
// substitutes a `!ref <name>` sentinel into. The
|
|
223
|
+
// adjacent `x-telo-ref` constraints govern the kind
|
|
224
|
+
// check; this branch only admits the structural form so
|
|
225
|
+
// AJV doesn't reject a resolved ref.
|
|
226
|
+
{
|
|
227
|
+
type: "object",
|
|
228
|
+
required: ["kind", "name"],
|
|
229
|
+
properties: {
|
|
230
|
+
kind: { type: "string" },
|
|
231
|
+
name: { type: "string" },
|
|
232
|
+
},
|
|
233
|
+
additionalProperties: true,
|
|
234
|
+
},
|
|
221
235
|
],
|
|
222
236
|
},
|
|
223
237
|
},
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dependency-graph.d.ts","sourceRoot":"","sources":["../src/dependency-graph.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"dependency-graph.d.ts","sourceRoot":"","sources":["../src/dependency-graph.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;AAInE,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,eAAe;IAC9B;kDAC8C;IAC9C,KAAK,CAAC,EAAE,aAAa,CAAC,YAAY,CAAC,CAAC;IACpC;oFACgF;IAChF,KAAK,CAAC,EAAE,aAAa,CAAC,YAAY,CAAC,CAAC;CACrC;AAID;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,kBAAkB,EAC5B,OAAO,CAAC,EAAE,aAAa,EACvB,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,GAC3C,eAAe,CAkHjB;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,aAAa,CAAC,YAAY,CAAC,GAAG,MAAM,CAOtE"}
|
package/dist/dependency-graph.js
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
|
+
import { isRefSentinel } from "@telorun/templating";
|
|
1
2
|
import { isRefEntry, isScopeEntry, resolveFieldValues } from "./reference-field-map.js";
|
|
2
|
-
|
|
3
|
-
* Module-identity docs (Telo.Application, Telo.Library) are intentionally
|
|
4
|
-
* not in this set: an Application's `targets` use `x-telo-ref` to real
|
|
5
|
-
* Runnable/Service resources, so the Application legitimately depends on
|
|
6
|
-
* them in boot order — modeling that as a graph edge is correct. A Library
|
|
7
|
-
* has no `targets`, so it becomes a zero-edge node, which is harmless.
|
|
8
|
-
* If the graph is ever consumed as "things to init", skip these kinds at
|
|
9
|
-
* the consumer site; the controller already runs them separately. */
|
|
10
|
-
const SYSTEM_KINDS = new Set(["Telo.Definition", "Telo.Import"]);
|
|
3
|
+
import { DEPENDENCY_GRAPH_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
|
|
11
4
|
const nodeKey = (kind, name) => `${kind}\0${name}`;
|
|
12
5
|
/**
|
|
13
6
|
* Builds a directed acyclic graph (DAG) of runtime resource dependencies and
|
|
@@ -22,13 +15,19 @@ const nodeKey = (kind, name) => `${kind}\0${name}`;
|
|
|
22
15
|
* not pre-compute or pass field maps separately.
|
|
23
16
|
*/
|
|
24
17
|
export function buildDependencyGraph(resources, registry, aliases, aliasesByModule) {
|
|
25
|
-
// --- Build node set ---
|
|
18
|
+
// --- Build node set + name index ---
|
|
26
19
|
const nodes = new Map();
|
|
20
|
+
// Sentinel lookup (`!ref <name>`) needs to resolve a bare name to its
|
|
21
|
+
// declared kind. Names are unique within a manifest scope, so a flat
|
|
22
|
+
// map suffices and lets the sentinel branch below avoid a full
|
|
23
|
+
// O(N) scan of the node set on every reference.
|
|
24
|
+
const nodesByName = new Map();
|
|
27
25
|
for (const r of resources) {
|
|
28
26
|
if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind))
|
|
29
27
|
continue;
|
|
30
|
-
const
|
|
31
|
-
nodes.set(
|
|
28
|
+
const node = { kind: r.kind, name: r.metadata.name };
|
|
29
|
+
nodes.set(nodeKey(node.kind, node.name), node);
|
|
30
|
+
nodesByName.set(node.name, node);
|
|
32
31
|
}
|
|
33
32
|
// --- Build adjacency: from → deps (from depends on dep) ---
|
|
34
33
|
const deps = new Map();
|
|
@@ -66,7 +65,22 @@ export function buildDependencyGraph(resources, registry, aliases, aliasesByModu
|
|
|
66
65
|
if (!isRefEntry(entry))
|
|
67
66
|
continue;
|
|
68
67
|
for (const val of resolveFieldValues(r, fieldPath)) {
|
|
69
|
-
if (!val
|
|
68
|
+
if (!val)
|
|
69
|
+
continue;
|
|
70
|
+
// `!ref <name>` sentinel — look up the target's kind from the
|
|
71
|
+
// name (resources are unique by name) so the edge carries the
|
|
72
|
+
// concrete kind, matching the {kind, name} edge shape below.
|
|
73
|
+
if (isRefSentinel(val)) {
|
|
74
|
+
const refName = val.source;
|
|
75
|
+
if (scopedNames.has(refName))
|
|
76
|
+
continue;
|
|
77
|
+
const node = nodesByName.get(refName);
|
|
78
|
+
if (node) {
|
|
79
|
+
deps.get(sourceKey).add(nodeKey(node.kind, node.name));
|
|
80
|
+
}
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (typeof val !== "object")
|
|
70
84
|
continue;
|
|
71
85
|
const ref = val;
|
|
72
86
|
if (!ref.kind || !ref.name)
|
|
@@ -15,7 +15,12 @@ export interface DocumentPosition {
|
|
|
15
15
|
export declare function buildDocumentPositions(text: string, parsedDocs: Document[]): DocumentPosition[];
|
|
16
16
|
/** Line numbers (0-indexed) where each YAML document in a multi-doc file
|
|
17
17
|
* starts. The first document is always at line 0; subsequent entries point
|
|
18
|
-
* to the line after each `---`
|
|
18
|
+
* to the line after each `---` separator.
|
|
19
|
+
*
|
|
20
|
+
* A `---` at line 0 is the doc-start marker for doc 0 (the parser still
|
|
21
|
+
* emits a single document), not a separator before an empty doc — skipping
|
|
22
|
+
* it keeps `offsets.length === parsedDocs.length` so diagnostics for doc N
|
|
23
|
+
* don't land inside doc N-1's text. */
|
|
19
24
|
export declare function documentLineOffsets(text: string): number[];
|
|
20
25
|
/** Byte-offset → start-of-line lookup table. Index `i` is the byte offset of
|
|
21
26
|
* the first character on line `i`. Used with `offsetToPosition` to turn a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"position-metadata.d.ts","sourceRoot":"","sources":["../src/position-metadata.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkC,KAAK,QAAQ,EAAE,MAAM,MAAM,CAAC;AACrE,OAAO,KAAK,EAAY,aAAa,EAAE,MAAM,YAAY,CAAC;AAE1D;;;;;oBAKoB;AAEpB,qFAAqF;AACrF,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,aAAa,CAAC;CAC9B;AAED,kEAAkE;AAClE,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,QAAQ,EAAE,GACrB,gBAAgB,EAAE,CAOpB;AAED
|
|
1
|
+
{"version":3,"file":"position-metadata.d.ts","sourceRoot":"","sources":["../src/position-metadata.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkC,KAAK,QAAQ,EAAE,MAAM,MAAM,CAAC;AACrE,OAAO,KAAK,EAAY,aAAa,EAAE,MAAM,YAAY,CAAC;AAE1D;;;;;oBAKoB;AAEpB,qFAAqF;AACrF,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,aAAa,CAAC;CAC9B;AAED,kEAAkE;AAClE,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,QAAQ,EAAE,GACrB,gBAAgB,EAAE,CAOpB;AAED;;;;;;;wCAOwC;AACxC,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAW1D;AAED;;kDAEkD;AAClD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAMvD;AAaD;;;;;+CAK+C;AAC/C,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,aAAa,CA0CtF"}
|
|
@@ -10,14 +10,22 @@ export function buildDocumentPositions(text, parsedDocs) {
|
|
|
10
10
|
}
|
|
11
11
|
/** Line numbers (0-indexed) where each YAML document in a multi-doc file
|
|
12
12
|
* starts. The first document is always at line 0; subsequent entries point
|
|
13
|
-
* to the line after each `---`
|
|
13
|
+
* to the line after each `---` separator.
|
|
14
|
+
*
|
|
15
|
+
* A `---` at line 0 is the doc-start marker for doc 0 (the parser still
|
|
16
|
+
* emits a single document), not a separator before an empty doc — skipping
|
|
17
|
+
* it keeps `offsets.length === parsedDocs.length` so diagnostics for doc N
|
|
18
|
+
* don't land inside doc N-1's text. */
|
|
14
19
|
export function documentLineOffsets(text) {
|
|
15
20
|
const offsets = [0];
|
|
16
21
|
const lines = text.split("\n");
|
|
17
22
|
for (let i = 0; i < lines.length; i++) {
|
|
18
23
|
const t = lines[i].trimEnd();
|
|
19
|
-
if (t === "---" || t.startsWith("--- "))
|
|
24
|
+
if (t === "---" || t.startsWith("--- ")) {
|
|
25
|
+
if (i === 0)
|
|
26
|
+
continue;
|
|
20
27
|
offsets.push(i + 1);
|
|
28
|
+
}
|
|
21
29
|
}
|
|
22
30
|
return offsets;
|
|
23
31
|
}
|
package/dist/precompile.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"precompile.d.ts","sourceRoot":"","sources":["../src/precompile.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAIxD;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,WAAW,GAAG,OAAO,
|
|
1
|
+
{"version":3,"file":"precompile.d.ts","sourceRoot":"","sources":["../src/precompile.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAIxD;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,WAAW,GAAG,OAAO,CA0CrE"}
|
package/dist/precompile.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isCompiledValue } from "@telorun/sdk";
|
|
2
|
-
import { compileString, defaultRegistry, isTaggedSentinel } from "@telorun/templating";
|
|
2
|
+
import { compileString, defaultRegistry, isRefSentinel, isTaggedSentinel } from "@telorun/templating";
|
|
3
3
|
/**
|
|
4
4
|
* Walks a raw YAML document and replaces all `${{ expr }}` strings (and
|
|
5
5
|
* `!cel`-tagged sentinels) with CompiledValue wrappers. Throws on CEL syntax
|
|
@@ -19,6 +19,14 @@ export function precompileDoc(doc, env) {
|
|
|
19
19
|
// analyzer's diagnostic walk can identify it on compiled trees too;
|
|
20
20
|
// engines returning plain values (e.g. `literal` → a string) pass through
|
|
21
21
|
// verbatim — the runtime contract is "any scalar value is fine."
|
|
22
|
+
// `!ref` sentinels are identity markers, not templating values. They must
|
|
23
|
+
// survive precompile intact so the analyzer's `resolveRefSentinels` pass
|
|
24
|
+
// can substitute them with `{kind, name}` objects against the resolved
|
|
25
|
+
// resource manifest. Running the engine's `compile` here would prematurely
|
|
26
|
+
// collapse the sentinel into its source string and the ref slot would
|
|
27
|
+
// arrive at the controller as a bare name with no kind.
|
|
28
|
+
if (isRefSentinel(doc))
|
|
29
|
+
return doc;
|
|
22
30
|
if (isTaggedSentinel(doc)) {
|
|
23
31
|
const engine = defaultRegistry().get(doc.engine);
|
|
24
32
|
if (!engine) {
|
|
@@ -98,7 +98,7 @@ export function buildReferenceFieldMap(schema) {
|
|
|
98
98
|
const map = new Map();
|
|
99
99
|
if (schema.properties) {
|
|
100
100
|
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
101
|
-
traverseNode(propSchema, key, map);
|
|
101
|
+
traverseNode(propSchema, key, map, schema);
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
return map;
|
|
@@ -123,10 +123,23 @@ function collectRefs(node) {
|
|
|
123
123
|
* hid behind the schema-from indirection. */
|
|
124
124
|
export function buildFieldMapAtPath(schema, pathPrefix) {
|
|
125
125
|
const map = new Map();
|
|
126
|
-
traverseNode(schema, pathPrefix, map);
|
|
126
|
+
traverseNode(schema, pathPrefix, map, schema);
|
|
127
127
|
return map;
|
|
128
128
|
}
|
|
129
|
-
function traverseNode(node, path, map) {
|
|
129
|
+
function traverseNode(node, path, map, root, visitedRefs = new Set()) {
|
|
130
|
+
// Local `$ref` is intentionally NOT followed. Descending into shared
|
|
131
|
+
// `$defs` (notably `Run.Sequence`'s `step` definition) would surface
|
|
132
|
+
// ref slots like `steps[].invoke` that Phase 5 then injects live
|
|
133
|
+
// instances into; today's `Run.Sequence` controller calls
|
|
134
|
+
// `instance.invoke()` directly when handed an instance, bypassing
|
|
135
|
+
// the kernel's `runInvoke` emit-Invoked path. The walker fix and the
|
|
136
|
+
// dispatcher fix need to land together — see the follow-up in
|
|
137
|
+
// [kernel/nodejs/plans/reference-syntax-unification.md] and the
|
|
138
|
+
// stopgap in `resource-context.ts:resolveChildren`. `visitedRefs`
|
|
139
|
+
// stays as a parameter so the recursive calls below thread the right
|
|
140
|
+
// signature; turning the descent back on is a single-branch change.
|
|
141
|
+
if (typeof node?.$ref === "string")
|
|
142
|
+
return;
|
|
130
143
|
// Scope slot — record and stop; do not recurse into scope contents
|
|
131
144
|
if ("x-telo-scope" in node) {
|
|
132
145
|
map.set(path, { scope: node["x-telo-scope"] });
|
|
@@ -148,12 +161,32 @@ function traverseNode(node, path, map) {
|
|
|
148
161
|
}
|
|
149
162
|
// Array — recurse into items
|
|
150
163
|
if (node.type === "array" && node.items) {
|
|
151
|
-
traverseNode(node.items, path + "[]", map);
|
|
164
|
+
traverseNode(node.items, path + "[]", map, root, visitedRefs);
|
|
152
165
|
}
|
|
153
166
|
// Object — recurse into properties
|
|
154
167
|
if (node.properties) {
|
|
155
168
|
for (const [key, propSchema] of Object.entries(node.properties)) {
|
|
156
|
-
traverseNode(propSchema, `${path}.${key}`, map);
|
|
169
|
+
traverseNode(propSchema, `${path}.${key}`, map, root, visitedRefs);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Variant branches — descend into every alternative's properties / items.
|
|
173
|
+
// Schemas that discriminate on shape (Run.Sequence's step kinds:
|
|
174
|
+
// `oneOf: [{properties: {invoke}}, {properties: {try}}, ...]`) hide ref
|
|
175
|
+
// slots inside the branch. Walking each branch surfaces those slots into
|
|
176
|
+
// the field map so downstream passes (ref validation, sentinel
|
|
177
|
+
// resolution, dependency graph) cover them without a runtime fallback.
|
|
178
|
+
// The same field path may be added by multiple branches; the later
|
|
179
|
+
// assignment wins, which is fine — branches with the same field path
|
|
180
|
+
// share the same ref/context configuration (any divergence is already
|
|
181
|
+
// a schema bug).
|
|
182
|
+
for (const variantKey of ["oneOf", "anyOf", "allOf"]) {
|
|
183
|
+
const variants = node[variantKey];
|
|
184
|
+
if (!Array.isArray(variants))
|
|
185
|
+
continue;
|
|
186
|
+
for (const variant of variants) {
|
|
187
|
+
if (!variant || typeof variant !== "object")
|
|
188
|
+
continue;
|
|
189
|
+
traverseVariant(variant, path, map, root, visitedRefs);
|
|
157
190
|
}
|
|
158
191
|
}
|
|
159
192
|
// Map — `additionalProperties: { ... }` describes every value in an
|
|
@@ -162,6 +195,25 @@ function traverseNode(node, path, map) {
|
|
|
162
195
|
if (node.additionalProperties &&
|
|
163
196
|
typeof node.additionalProperties === "object" &&
|
|
164
197
|
!Array.isArray(node.additionalProperties)) {
|
|
165
|
-
traverseNode(node.additionalProperties, `${path}.{}`, map);
|
|
198
|
+
traverseNode(node.additionalProperties, `${path}.{}`, map, root, visitedRefs);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** Walk a single variant of a `oneOf` / `anyOf` / `allOf` branch. Only
|
|
202
|
+
* the properties / items / map slots are followed — collectRefs at the
|
|
203
|
+
* variant root is handled by the parent's `collectRefs(node)` already
|
|
204
|
+
* (anyOf of x-telo-ref branches is the canonical multi-ref shape). */
|
|
205
|
+
function traverseVariant(variant, path, map, root, visitedRefs = new Set()) {
|
|
206
|
+
if (variant.properties) {
|
|
207
|
+
for (const [key, propSchema] of Object.entries(variant.properties)) {
|
|
208
|
+
traverseNode(propSchema, `${path}.${key}`, map, root, visitedRefs);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (variant.type === "array" && variant.items) {
|
|
212
|
+
traverseNode(variant.items, path + "[]", map, root, visitedRefs);
|
|
213
|
+
}
|
|
214
|
+
if (variant.additionalProperties &&
|
|
215
|
+
typeof variant.additionalProperties === "object" &&
|
|
216
|
+
!Array.isArray(variant.additionalProperties)) {
|
|
217
|
+
traverseNode(variant.additionalProperties, `${path}.{}`, map, root, visitedRefs);
|
|
166
218
|
}
|
|
167
219
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import type { AliasResolver } from "./alias-resolver.js";
|
|
3
|
+
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
4
|
+
/**
|
|
5
|
+
* Walks every `x-telo-ref` slot in every non-system resource and rewrites
|
|
6
|
+
* `!ref <name>` sentinels in-place to `{kind: <resolved-kind>, name}`.
|
|
7
|
+
*
|
|
8
|
+
* The downstream pipeline (inline normalization, dependency graph, kernel
|
|
9
|
+
* controllers) expects every ref-slot value to be either a `{kind, name}`
|
|
10
|
+
* object, an inline-definition object, or a legacy bare string — resolving
|
|
11
|
+
* sentinels here keeps that contract intact so each consumer doesn't need
|
|
12
|
+
* its own sentinel branch.
|
|
13
|
+
*
|
|
14
|
+
* The walker assigns `kind` by name lookup (resource names are unique
|
|
15
|
+
* within a manifest scope). When the name doesn't resolve in the local
|
|
16
|
+
* `byName` map, the sentinel is left in place so `validateReferences`
|
|
17
|
+
* can emit the `UNRESOLVED_REFERENCE` diagnostic with full context.
|
|
18
|
+
*
|
|
19
|
+
* Mutation strategy: the field-path walker descends the resource tree
|
|
20
|
+
* directly and replaces the sentinel on its parent container. Re-parsing
|
|
21
|
+
* a string-encoded concrete path (the earlier shape) coupled the writer
|
|
22
|
+
* to the path-encoding rules of `resolveFieldEntries` — any new path
|
|
23
|
+
* marker would silently break this writer. Descending directly avoids
|
|
24
|
+
* that coupling.
|
|
25
|
+
*/
|
|
26
|
+
export declare function resolveRefSentinels(resources: ResourceManifest[], registry: DefinitionRegistry, aliases?: AliasResolver, aliasesByModule?: Map<string, AliasResolver>): void;
|
|
27
|
+
//# sourceMappingURL=resolve-ref-sentinels.d.ts.map
|
|
@@ -0,0 +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;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAInE;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,kBAAkB,EAC5B,OAAO,CAAC,EAAE,aAAa,EACvB,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,GAC3C,IAAI,CAsBN"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { isRefSentinel } from "@telorun/templating";
|
|
2
|
+
import { isRefEntry } from "./reference-field-map.js";
|
|
3
|
+
import { REF_RESOLUTION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
|
|
4
|
+
/**
|
|
5
|
+
* Walks every `x-telo-ref` slot in every non-system resource and rewrites
|
|
6
|
+
* `!ref <name>` sentinels in-place to `{kind: <resolved-kind>, name}`.
|
|
7
|
+
*
|
|
8
|
+
* The downstream pipeline (inline normalization, dependency graph, kernel
|
|
9
|
+
* controllers) expects every ref-slot value to be either a `{kind, name}`
|
|
10
|
+
* object, an inline-definition object, or a legacy bare string — resolving
|
|
11
|
+
* sentinels here keeps that contract intact so each consumer doesn't need
|
|
12
|
+
* its own sentinel branch.
|
|
13
|
+
*
|
|
14
|
+
* The walker assigns `kind` by name lookup (resource names are unique
|
|
15
|
+
* within a manifest scope). When the name doesn't resolve in the local
|
|
16
|
+
* `byName` map, the sentinel is left in place so `validateReferences`
|
|
17
|
+
* can emit the `UNRESOLVED_REFERENCE` diagnostic with full context.
|
|
18
|
+
*
|
|
19
|
+
* Mutation strategy: the field-path walker descends the resource tree
|
|
20
|
+
* directly and replaces the sentinel on its parent container. Re-parsing
|
|
21
|
+
* a string-encoded concrete path (the earlier shape) coupled the writer
|
|
22
|
+
* to the path-encoding rules of `resolveFieldEntries` — any new path
|
|
23
|
+
* marker would silently break this writer. Descending directly avoids
|
|
24
|
+
* that coupling.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveRefSentinels(resources, registry, aliases, aliasesByModule) {
|
|
27
|
+
const byName = new Map();
|
|
28
|
+
for (const r of resources) {
|
|
29
|
+
if (r.metadata?.name && !SYSTEM_KINDS.has(r.kind)) {
|
|
30
|
+
byName.set(r.metadata.name, r);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
for (const r of resources) {
|
|
34
|
+
if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind))
|
|
35
|
+
continue;
|
|
36
|
+
const fieldMap = aliases && aliasesByModule
|
|
37
|
+
? registry.expandedFieldMapForResource(r, aliases, aliasesByModule)
|
|
38
|
+
: registry.getFieldMapForKind(r.kind, aliases);
|
|
39
|
+
if (!fieldMap)
|
|
40
|
+
continue;
|
|
41
|
+
for (const [fieldPath, entry] of fieldMap) {
|
|
42
|
+
if (!isRefEntry(entry))
|
|
43
|
+
continue;
|
|
44
|
+
replaceSentinelsAtPath(r, fieldPath, byName);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Walks `obj` along `fieldPath` (dot notation with `[]` for arrays and
|
|
49
|
+
* `{}` for additionalProperties-typed maps) and replaces any `!ref`
|
|
50
|
+
* sentinel value at the terminal slot with `{kind, name}` looked up
|
|
51
|
+
* via `byName`. Mutates the parent container in place; no string-path
|
|
52
|
+
* round-trip. */
|
|
53
|
+
function replaceSentinelsAtPath(obj, fieldPath, byName) {
|
|
54
|
+
const parts = fieldPath.split(".");
|
|
55
|
+
descend(obj, parts, byName);
|
|
56
|
+
}
|
|
57
|
+
function descend(obj, parts, byName) {
|
|
58
|
+
if (obj == null || typeof obj !== "object" || parts.length === 0)
|
|
59
|
+
return;
|
|
60
|
+
const [head, ...rest] = parts;
|
|
61
|
+
// Map iteration: descend into every value of the current object.
|
|
62
|
+
if (head === "{}") {
|
|
63
|
+
const container = obj;
|
|
64
|
+
for (const key of Object.keys(container)) {
|
|
65
|
+
const child = container[key];
|
|
66
|
+
if (rest.length === 0) {
|
|
67
|
+
if (isRefSentinel(child)) {
|
|
68
|
+
const target = byName.get(child.source);
|
|
69
|
+
if (target)
|
|
70
|
+
container[key] = { kind: target.kind, name: child.source };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
descend(child, rest, byName);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const isArr = head.endsWith("[]");
|
|
80
|
+
const key = isArr ? head.slice(0, -2) : head;
|
|
81
|
+
const container = obj;
|
|
82
|
+
const val = container[key];
|
|
83
|
+
if (val == null)
|
|
84
|
+
return;
|
|
85
|
+
if (isArr) {
|
|
86
|
+
if (!Array.isArray(val))
|
|
87
|
+
return;
|
|
88
|
+
for (let i = 0; i < val.length; i++) {
|
|
89
|
+
if (rest.length === 0) {
|
|
90
|
+
const elem = val[i];
|
|
91
|
+
if (isRefSentinel(elem)) {
|
|
92
|
+
const target = byName.get(elem.source);
|
|
93
|
+
if (target)
|
|
94
|
+
val[i] = { kind: target.kind, name: elem.source };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
descend(val[i], rest, byName);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
if (rest.length === 0) {
|
|
104
|
+
if (isRefSentinel(val)) {
|
|
105
|
+
const target = byName.get(val.source);
|
|
106
|
+
if (target)
|
|
107
|
+
container[key] = { kind: target.kind, name: val.source };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
descend(val, rest, byName);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
package/dist/schema-compat.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
declare const Ajv: any;
|
|
2
2
|
/** Creates a configured AJV instance (allErrors, strict: false, with formats).
|
|
3
|
-
*
|
|
3
|
+
* Also registers the kernel manifest root schema under `telo://manifest` so
|
|
4
|
+
* module YAMLs can `$ref` into the shared `$defs/ResourceRef` (and any future
|
|
5
|
+
* shared fragments) from this analyzer's AJV without each module having to
|
|
6
|
+
* bundle its own copy.
|
|
7
|
+
*
|
|
8
|
+
* Called once for the module-level instance and once per
|
|
9
|
+
* DefinitionRegistry instance. */
|
|
4
10
|
export declare function createAjv(): InstanceType<typeof Ajv>;
|
|
5
11
|
export interface CompatibilityResult {
|
|
6
12
|
compatible: boolean;
|
|
@@ -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;
|
|
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;AAED,0GAA0G;AAC1G,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,WAAW,EAAE,CAe/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,8DAA8D;AAC9D,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,GAAG,MAAM,CAuBnF;AAED,wFAAwF;AACxF,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,OAAO,CAqBhG;AAED,6EAA6E;AAC7E,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,OAAO,CAiB5E;AA2BD;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,14 +1,21 @@
|
|
|
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
|
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
|
|
7
|
+
* module YAMLs can `$ref` into the shared `$defs/ResourceRef` (and any future
|
|
8
|
+
* shared fragments) from this analyzer's AJV without each module having to
|
|
9
|
+
* bundle its own copy.
|
|
10
|
+
*
|
|
11
|
+
* Called once for the module-level instance and once per
|
|
12
|
+
* DefinitionRegistry instance. */
|
|
7
13
|
export function createAjv() {
|
|
8
14
|
const instance = new Ajv({ allErrors: true, strict: false });
|
|
9
15
|
addFormats.default
|
|
10
16
|
? addFormats.default(instance)
|
|
11
17
|
: addFormats(instance);
|
|
18
|
+
instance.addSchema(ManifestRootSchema);
|
|
12
19
|
return instance;
|
|
13
20
|
}
|
|
14
21
|
const ajv = createAjv();
|
|
@@ -270,6 +277,16 @@ export function substituteCelFields(data, schema, rootSchema) {
|
|
|
270
277
|
if (typeof data === "string" && CEL_PURE_RE.test(data)) {
|
|
271
278
|
return celPlaceholderForSchema(resolved);
|
|
272
279
|
}
|
|
280
|
+
// `!ref <name>` sentinels are identity markers, not runtime values —
|
|
281
|
+
// schemas that opt into `$ref: "telo://manifest#/$defs/ResourceRef"`
|
|
282
|
+
// (or `anyOf` it alongside other shapes) need the actual sentinel
|
|
283
|
+
// object so AJV validates it against ResourceRefSchema. Collapsing it
|
|
284
|
+
// to a CEL placeholder would either fail the schema (when the slot
|
|
285
|
+
// expects the ResourceRef shape) or mask validation errors (when the
|
|
286
|
+
// slot expects something else entirely).
|
|
287
|
+
if (isRefSentinel(data)) {
|
|
288
|
+
return data;
|
|
289
|
+
}
|
|
273
290
|
if (isTaggedSentinel(data)) {
|
|
274
291
|
return celPlaceholderForSchema(resolved);
|
|
275
292
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
/** Skipped by reference validation: type blueprints whose own ref slots
|
|
7
|
+
* belong to a different phase (definition schema validation rather than
|
|
8
|
+
* per-resource validation). Telo.Application and Telo.Library
|
|
9
|
+
* intentionally fall through — Application has `targets` (real refs) and
|
|
10
|
+
* Library is harmless (no ref-bearing fields). Telo.Import is also
|
|
11
|
+
* intentionally not skipped — its `source` is not an x-telo-ref slot, so
|
|
12
|
+
* walking it is cheap and consistent. */
|
|
13
|
+
export declare const REF_VALIDATION_SKIP_KINDS: ReadonlySet<string>;
|
|
14
|
+
/** Excluded from the dependency graph: kinds that are not runtime nodes.
|
|
15
|
+
* Telo.Abstract is intentionally not in this set today — abstracts have
|
|
16
|
+
* no resource manifests, so they never reach graph construction; if
|
|
17
|
+
* that ever changes, add it explicitly. */
|
|
18
|
+
export declare const DEPENDENCY_GRAPH_SKIP_KINDS: ReadonlySet<string>;
|
|
19
|
+
/** Skipped by `!ref` sentinel resolution: kinds whose bodies are
|
|
20
|
+
* blueprints or import-time metadata, not resource instances with
|
|
21
|
+
* user-referenced ref slots. Mirrors `REF_VALIDATION_SKIP_KINDS` but
|
|
22
|
+
* also drops Telo.Import (its `source` isn't a ref slot, and walking
|
|
23
|
+
* the field map on it is pointless since there's no registered kind). */
|
|
24
|
+
export declare const REF_RESOLUTION_SKIP_KINDS: ReadonlySet<string>;
|
|
25
|
+
//# sourceMappingURL=system-kinds.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"system-kinds.d.ts","sourceRoot":"","sources":["../src/system-kinds.ts"],"names":[],"mappings":"AAAA;;;;sEAIsE;AAEtE;;;;;;0CAM0C;AAC1C,eAAO,MAAM,yBAAyB,EAAE,WAAW,CAAC,MAAM,CAGxD,CAAC;AAEH;;;4CAG4C;AAC5C,eAAO,MAAM,2BAA2B,EAAE,WAAW,CAAC,MAAM,CAG1D,CAAC;AAEH;;;;0EAI0E;AAC1E,eAAO,MAAM,yBAAyB,EAAE,WAAW,CAAC,MAAM,CAIxD,CAAC"}
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
/** Skipped by reference validation: type blueprints whose own ref slots
|
|
7
|
+
* belong to a different phase (definition schema validation rather than
|
|
8
|
+
* per-resource validation). Telo.Application and Telo.Library
|
|
9
|
+
* intentionally fall through — Application has `targets` (real refs) and
|
|
10
|
+
* Library is harmless (no ref-bearing fields). Telo.Import is also
|
|
11
|
+
* intentionally not skipped — its `source` is not an x-telo-ref slot, so
|
|
12
|
+
* walking it is cheap and consistent. */
|
|
13
|
+
export const REF_VALIDATION_SKIP_KINDS = new Set([
|
|
14
|
+
"Telo.Definition",
|
|
15
|
+
"Telo.Abstract",
|
|
16
|
+
]);
|
|
17
|
+
/** Excluded from the dependency graph: kinds that are not runtime nodes.
|
|
18
|
+
* Telo.Abstract is intentionally not in this set today — abstracts have
|
|
19
|
+
* no resource manifests, so they never reach graph construction; if
|
|
20
|
+
* that ever changes, add it explicitly. */
|
|
21
|
+
export const DEPENDENCY_GRAPH_SKIP_KINDS = new Set([
|
|
22
|
+
"Telo.Definition",
|
|
23
|
+
"Telo.Import",
|
|
24
|
+
]);
|
|
25
|
+
/** Skipped by `!ref` sentinel resolution: kinds whose bodies are
|
|
26
|
+
* blueprints or import-time metadata, not resource instances with
|
|
27
|
+
* user-referenced ref slots. Mirrors `REF_VALIDATION_SKIP_KINDS` but
|
|
28
|
+
* also drops Telo.Import (its `source` isn't a ref slot, and walking
|
|
29
|
+
* the field map on it is pointless since there's no registered kind). */
|
|
30
|
+
export const REF_RESOLUTION_SKIP_KINDS = new Set([
|
|
31
|
+
"Telo.Definition",
|
|
32
|
+
"Telo.Abstract",
|
|
33
|
+
"Telo.Import",
|
|
34
|
+
]);
|
|
@@ -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;
|
|
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;AAKrD,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,CA2WtB"}
|
|
@@ -1,13 +1,9 @@
|
|
|
1
|
+
import { isRefSentinel } from "@telorun/templating";
|
|
1
2
|
import { isRefEntry, isScopeEntry, isSchemaFromEntry, isInlineResource, resolveFieldEntries, resolveFieldValues } from "./reference-field-map.js";
|
|
2
3
|
import { navigateJsonPointer } from "./schema-compat.js";
|
|
4
|
+
import { REF_VALIDATION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
|
|
3
5
|
import { DiagnosticSeverity } from "./types.js";
|
|
4
6
|
const SOURCE = "telo-analyzer";
|
|
5
|
-
/** Kinds skipped by reference validation. Telo.Application and Telo.Library
|
|
6
|
-
* are intentionally not here: Application has `targets` with x-telo-ref that
|
|
7
|
-
* must be validated, and Library has no ref-bearing fields so flows through
|
|
8
|
-
* harmlessly. Telo.Import is also not here for the same reason — its
|
|
9
|
-
* `source` field isn't x-telo-ref, so nothing gets checked. */
|
|
10
|
-
const SYSTEM_KINDS = new Set(["Telo.Definition", "Telo.Abstract"]);
|
|
11
7
|
/**
|
|
12
8
|
* Checks whether `kind` satisfies the ref constraint in `entry`.
|
|
13
9
|
* Returns an empty array when valid, or mismatch error strings when not.
|
|
@@ -122,6 +118,36 @@ export function validateReferences(resources, context) {
|
|
|
122
118
|
for (const { value: val, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
|
|
123
119
|
if (!val)
|
|
124
120
|
continue;
|
|
121
|
+
// `!ref <name>` sentinel — bare resource name marked at parse time as a
|
|
122
|
+
// reference. Look it up against the slot's x-telo-ref constraint exactly
|
|
123
|
+
// like the legacy bare-string path; the only difference is the value's
|
|
124
|
+
// shape (a TaggedSentinel rather than a raw string), which removed the
|
|
125
|
+
// string/inline ambiguity at the source.
|
|
126
|
+
if (isRefSentinel(val)) {
|
|
127
|
+
const refName = val.source;
|
|
128
|
+
const target = byName.get(refName) ?? visibleScopeManifests.find((m) => m.metadata?.name === refName);
|
|
129
|
+
if (!target) {
|
|
130
|
+
diagnostics.push({
|
|
131
|
+
severity: DiagnosticSeverity.Error,
|
|
132
|
+
code: "UNRESOLVED_REFERENCE",
|
|
133
|
+
source: SOURCE,
|
|
134
|
+
message: `${resourceLabel}: reference at '${concretePath}' → resource '${refName}' not found`,
|
|
135
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
136
|
+
});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const kindErrors = checkKind(target.kind, entry, registry, aliases);
|
|
140
|
+
if (kindErrors.length > 0) {
|
|
141
|
+
diagnostics.push({
|
|
142
|
+
severity: DiagnosticSeverity.Error,
|
|
143
|
+
code: "REFERENCE_KIND_MISMATCH",
|
|
144
|
+
source: SOURCE,
|
|
145
|
+
message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
|
|
146
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
125
151
|
// Name-only reference (plain string) — look up by name to validate.
|
|
126
152
|
// Qualified references use "Kind.Name" format (e.g. "Http.Api.PaymentApi");
|
|
127
153
|
// extract the resource name from the last dot segment.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/analyzer",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Telo Analyzer - Static manifest validator for Telo manifests.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"ajv-formats": "^3.0.1",
|
|
43
43
|
"jsonpath-plus": "^10.3.0",
|
|
44
44
|
"yaml": "^2.8.3",
|
|
45
|
-
"@telorun/templating": "1.
|
|
45
|
+
"@telorun/templating": "1.1.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/node": "^20.0.0",
|
package/src/analyzer.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { buildKernelGlobalsSchema, mergeKernelGlobalsIntoContext } from "./kerne
|
|
|
14
14
|
import { computeSuggestKind } from "./kind-suggest.js";
|
|
15
15
|
import { isModuleKind } from "./module-kinds.js";
|
|
16
16
|
import { normalizeInlineResources } from "./normalize-inline-resources.js";
|
|
17
|
+
import { resolveRefSentinels } from "./resolve-ref-sentinels.js";
|
|
17
18
|
import { rewriteSyntheticOrigins } from "./rewrite-synthetic-origins.js";
|
|
18
19
|
import {
|
|
19
20
|
celTypeSatisfiesJsonSchema,
|
|
@@ -623,6 +624,12 @@ export class StaticAnalyzer {
|
|
|
623
624
|
// Phase 2: extract inline resources from x-telo-ref slots into first-class manifests
|
|
624
625
|
const allManifests = normalizeInlineResources(manifests, defs, aliases, aliasesByModule);
|
|
625
626
|
|
|
627
|
+
// Phase 2.5: resolve `!ref <name>` sentinels at every ref slot to canonical
|
|
628
|
+
// {kind, name} objects so downstream phases (validation, dependency graph,
|
|
629
|
+
// kernel controllers) see a uniform shape. Runs after normalize so both
|
|
630
|
+
// original and inline-extracted manifests have their sentinels resolved.
|
|
631
|
+
resolveRefSentinels(allManifests, defs, aliases, aliasesByModule);
|
|
632
|
+
|
|
626
633
|
// Trusted-input fast path: when the caller has already attested that
|
|
627
634
|
// this exact manifest set passes analysis (e.g. via the kernel's
|
|
628
635
|
// hash-stamped `.validated.json` cache), skip the validation walk.
|
|
@@ -982,12 +989,17 @@ export class StaticAnalyzer {
|
|
|
982
989
|
|
|
983
990
|
normalize(manifests: ResourceManifest[], registry: AnalysisRegistry): ResourceManifest[] {
|
|
984
991
|
const ctx = registry._context();
|
|
985
|
-
|
|
992
|
+
const normalized = normalizeInlineResources(
|
|
986
993
|
manifests,
|
|
987
994
|
ctx.definitions!,
|
|
988
995
|
ctx.aliases,
|
|
989
996
|
ctx.aliasesByModule,
|
|
990
997
|
);
|
|
998
|
+
// Resolve !ref sentinels after normalize so both the original and
|
|
999
|
+
// inline-extracted manifests get their refs canonicalized to
|
|
1000
|
+
// {kind, name} for the kernel that consumes this output.
|
|
1001
|
+
resolveRefSentinels(normalized, ctx.definitions!, ctx.aliases, ctx.aliasesByModule);
|
|
1002
|
+
return normalized;
|
|
991
1003
|
}
|
|
992
1004
|
|
|
993
1005
|
prepare(
|
package/src/builtins.ts
CHANGED
|
@@ -220,6 +220,20 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
|
|
|
220
220
|
anyOf: [
|
|
221
221
|
{ type: "string", "x-telo-ref": "telo#Runnable" },
|
|
222
222
|
{ type: "string", "x-telo-ref": "telo#Service" },
|
|
223
|
+
// Post-resolution shape that `resolveRefSentinels`
|
|
224
|
+
// substitutes a `!ref <name>` sentinel into. The
|
|
225
|
+
// adjacent `x-telo-ref` constraints govern the kind
|
|
226
|
+
// check; this branch only admits the structural form so
|
|
227
|
+
// AJV doesn't reject a resolved ref.
|
|
228
|
+
{
|
|
229
|
+
type: "object",
|
|
230
|
+
required: ["kind", "name"],
|
|
231
|
+
properties: {
|
|
232
|
+
kind: { type: "string" },
|
|
233
|
+
name: { type: "string" },
|
|
234
|
+
},
|
|
235
|
+
additionalProperties: true,
|
|
236
|
+
},
|
|
223
237
|
],
|
|
224
238
|
},
|
|
225
239
|
},
|
package/src/dependency-graph.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { isRefSentinel } from "@telorun/templating";
|
|
2
3
|
import type { AliasResolver } from "./alias-resolver.js";
|
|
3
4
|
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
4
5
|
import { isRefEntry, isScopeEntry, resolveFieldValues } from "./reference-field-map.js";
|
|
6
|
+
import { DEPENDENCY_GRAPH_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
|
|
5
7
|
|
|
6
8
|
export interface ResourceNode {
|
|
7
9
|
kind: string;
|
|
@@ -17,16 +19,6 @@ export interface DependencyGraph {
|
|
|
17
19
|
cycle?: ReadonlyArray<ResourceNode>;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
/** System resource kinds that are not runtime nodes in the dependency graph.
|
|
21
|
-
* Module-identity docs (Telo.Application, Telo.Library) are intentionally
|
|
22
|
-
* not in this set: an Application's `targets` use `x-telo-ref` to real
|
|
23
|
-
* Runnable/Service resources, so the Application legitimately depends on
|
|
24
|
-
* them in boot order — modeling that as a graph edge is correct. A Library
|
|
25
|
-
* has no `targets`, so it becomes a zero-edge node, which is harmless.
|
|
26
|
-
* If the graph is ever consumed as "things to init", skip these kinds at
|
|
27
|
-
* the consumer site; the controller already runs them separately. */
|
|
28
|
-
const SYSTEM_KINDS = new Set(["Telo.Definition", "Telo.Import"]);
|
|
29
|
-
|
|
30
22
|
const nodeKey = (kind: string, name: string) => `${kind}\0${name}`;
|
|
31
23
|
|
|
32
24
|
/**
|
|
@@ -47,12 +39,18 @@ export function buildDependencyGraph(
|
|
|
47
39
|
aliases?: AliasResolver,
|
|
48
40
|
aliasesByModule?: Map<string, AliasResolver>,
|
|
49
41
|
): DependencyGraph {
|
|
50
|
-
// --- Build node set ---
|
|
42
|
+
// --- Build node set + name index ---
|
|
51
43
|
const nodes = new Map<string, ResourceNode>();
|
|
44
|
+
// Sentinel lookup (`!ref <name>`) needs to resolve a bare name to its
|
|
45
|
+
// declared kind. Names are unique within a manifest scope, so a flat
|
|
46
|
+
// map suffices and lets the sentinel branch below avoid a full
|
|
47
|
+
// O(N) scan of the node set on every reference.
|
|
48
|
+
const nodesByName = new Map<string, ResourceNode>();
|
|
52
49
|
for (const r of resources) {
|
|
53
50
|
if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
|
|
54
|
-
const
|
|
55
|
-
nodes.set(
|
|
51
|
+
const node = { kind: r.kind, name: r.metadata.name as string };
|
|
52
|
+
nodes.set(nodeKey(node.kind, node.name), node);
|
|
53
|
+
nodesByName.set(node.name, node);
|
|
56
54
|
}
|
|
57
55
|
|
|
58
56
|
// --- Build adjacency: from → deps (from depends on dep) ---
|
|
@@ -90,7 +88,22 @@ export function buildDependencyGraph(
|
|
|
90
88
|
if (!isRefEntry(entry)) continue;
|
|
91
89
|
|
|
92
90
|
for (const val of resolveFieldValues(r, fieldPath)) {
|
|
93
|
-
if (!val
|
|
91
|
+
if (!val) continue;
|
|
92
|
+
|
|
93
|
+
// `!ref <name>` sentinel — look up the target's kind from the
|
|
94
|
+
// name (resources are unique by name) so the edge carries the
|
|
95
|
+
// concrete kind, matching the {kind, name} edge shape below.
|
|
96
|
+
if (isRefSentinel(val)) {
|
|
97
|
+
const refName = val.source;
|
|
98
|
+
if (scopedNames.has(refName)) continue;
|
|
99
|
+
const node = nodesByName.get(refName);
|
|
100
|
+
if (node) {
|
|
101
|
+
deps.get(sourceKey)!.add(nodeKey(node.kind, node.name));
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (typeof val !== "object") continue;
|
|
94
107
|
const ref = val as Record<string, unknown>;
|
|
95
108
|
if (!ref.kind || !ref.name) continue;
|
|
96
109
|
// Edges to scoped resources are runtime deps, not boot-time deps — exclude from DAG
|
package/src/position-metadata.ts
CHANGED
|
@@ -29,13 +29,21 @@ export function buildDocumentPositions(
|
|
|
29
29
|
|
|
30
30
|
/** Line numbers (0-indexed) where each YAML document in a multi-doc file
|
|
31
31
|
* starts. The first document is always at line 0; subsequent entries point
|
|
32
|
-
* to the line after each `---`
|
|
32
|
+
* to the line after each `---` separator.
|
|
33
|
+
*
|
|
34
|
+
* A `---` at line 0 is the doc-start marker for doc 0 (the parser still
|
|
35
|
+
* emits a single document), not a separator before an empty doc — skipping
|
|
36
|
+
* it keeps `offsets.length === parsedDocs.length` so diagnostics for doc N
|
|
37
|
+
* don't land inside doc N-1's text. */
|
|
33
38
|
export function documentLineOffsets(text: string): number[] {
|
|
34
39
|
const offsets = [0];
|
|
35
40
|
const lines = text.split("\n");
|
|
36
41
|
for (let i = 0; i < lines.length; i++) {
|
|
37
42
|
const t = lines[i].trimEnd();
|
|
38
|
-
if (t === "---" || t.startsWith("--- "))
|
|
43
|
+
if (t === "---" || t.startsWith("--- ")) {
|
|
44
|
+
if (i === 0) continue;
|
|
45
|
+
offsets.push(i + 1);
|
|
46
|
+
}
|
|
39
47
|
}
|
|
40
48
|
return offsets;
|
|
41
49
|
}
|
package/src/precompile.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Environment } from "@marcbachmann/cel-js";
|
|
2
2
|
import { isCompiledValue } from "@telorun/sdk";
|
|
3
|
-
import { compileString, defaultRegistry, isTaggedSentinel } from "@telorun/templating";
|
|
3
|
+
import { compileString, defaultRegistry, isRefSentinel, isTaggedSentinel } from "@telorun/templating";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Walks a raw YAML document and replaces all `${{ expr }}` strings (and
|
|
@@ -21,6 +21,13 @@ export function precompileDoc(doc: unknown, env: Environment): unknown {
|
|
|
21
21
|
// analyzer's diagnostic walk can identify it on compiled trees too;
|
|
22
22
|
// engines returning plain values (e.g. `literal` → a string) pass through
|
|
23
23
|
// verbatim — the runtime contract is "any scalar value is fine."
|
|
24
|
+
// `!ref` sentinels are identity markers, not templating values. They must
|
|
25
|
+
// survive precompile intact so the analyzer's `resolveRefSentinels` pass
|
|
26
|
+
// can substitute them with `{kind, name}` objects against the resolved
|
|
27
|
+
// resource manifest. Running the engine's `compile` here would prematurely
|
|
28
|
+
// collapse the sentinel into its source string and the ref slot would
|
|
29
|
+
// arrive at the controller as a bare name with no kind.
|
|
30
|
+
if (isRefSentinel(doc)) return doc;
|
|
24
31
|
if (isTaggedSentinel(doc)) {
|
|
25
32
|
const engine = defaultRegistry().get(doc.engine);
|
|
26
33
|
if (!engine) {
|
|
@@ -142,7 +142,7 @@ export function buildReferenceFieldMap(schema: Record<string, any>): ReferenceFi
|
|
|
142
142
|
const map: ReferenceFieldMap = new Map();
|
|
143
143
|
if (schema.properties) {
|
|
144
144
|
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
145
|
-
traverseNode(propSchema as Record<string, any>, key, map);
|
|
145
|
+
traverseNode(propSchema as Record<string, any>, key, map, schema);
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
return map;
|
|
@@ -172,11 +172,29 @@ export function buildFieldMapAtPath(
|
|
|
172
172
|
pathPrefix: string,
|
|
173
173
|
): ReferenceFieldMap {
|
|
174
174
|
const map: ReferenceFieldMap = new Map();
|
|
175
|
-
traverseNode(schema, pathPrefix, map);
|
|
175
|
+
traverseNode(schema, pathPrefix, map, schema);
|
|
176
176
|
return map;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
function traverseNode(
|
|
179
|
+
function traverseNode(
|
|
180
|
+
node: Record<string, any>,
|
|
181
|
+
path: string,
|
|
182
|
+
map: ReferenceFieldMap,
|
|
183
|
+
root?: Record<string, any>,
|
|
184
|
+
visitedRefs: Set<string> = new Set(),
|
|
185
|
+
): void {
|
|
186
|
+
// Local `$ref` is intentionally NOT followed. Descending into shared
|
|
187
|
+
// `$defs` (notably `Run.Sequence`'s `step` definition) would surface
|
|
188
|
+
// ref slots like `steps[].invoke` that Phase 5 then injects live
|
|
189
|
+
// instances into; today's `Run.Sequence` controller calls
|
|
190
|
+
// `instance.invoke()` directly when handed an instance, bypassing
|
|
191
|
+
// the kernel's `runInvoke` emit-Invoked path. The walker fix and the
|
|
192
|
+
// dispatcher fix need to land together — see the follow-up in
|
|
193
|
+
// [kernel/nodejs/plans/reference-syntax-unification.md] and the
|
|
194
|
+
// stopgap in `resource-context.ts:resolveChildren`. `visitedRefs`
|
|
195
|
+
// stays as a parameter so the recursive calls below thread the right
|
|
196
|
+
// signature; turning the descent back on is a single-branch change.
|
|
197
|
+
if (typeof node?.$ref === "string") return;
|
|
180
198
|
// Scope slot — record and stop; do not recurse into scope contents
|
|
181
199
|
if ("x-telo-scope" in node) {
|
|
182
200
|
map.set(path, { scope: node["x-telo-scope"] });
|
|
@@ -200,13 +218,32 @@ function traverseNode(node: Record<string, any>, path: string, map: ReferenceFie
|
|
|
200
218
|
|
|
201
219
|
// Array — recurse into items
|
|
202
220
|
if (node.type === "array" && node.items) {
|
|
203
|
-
traverseNode(node.items as Record<string, any>, path + "[]", map);
|
|
221
|
+
traverseNode(node.items as Record<string, any>, path + "[]", map, root, visitedRefs);
|
|
204
222
|
}
|
|
205
223
|
|
|
206
224
|
// Object — recurse into properties
|
|
207
225
|
if (node.properties) {
|
|
208
226
|
for (const [key, propSchema] of Object.entries(node.properties)) {
|
|
209
|
-
traverseNode(propSchema as Record<string, any>, `${path}.${key}`, map);
|
|
227
|
+
traverseNode(propSchema as Record<string, any>, `${path}.${key}`, map, root, visitedRefs);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Variant branches — descend into every alternative's properties / items.
|
|
232
|
+
// Schemas that discriminate on shape (Run.Sequence's step kinds:
|
|
233
|
+
// `oneOf: [{properties: {invoke}}, {properties: {try}}, ...]`) hide ref
|
|
234
|
+
// slots inside the branch. Walking each branch surfaces those slots into
|
|
235
|
+
// the field map so downstream passes (ref validation, sentinel
|
|
236
|
+
// resolution, dependency graph) cover them without a runtime fallback.
|
|
237
|
+
// The same field path may be added by multiple branches; the later
|
|
238
|
+
// assignment wins, which is fine — branches with the same field path
|
|
239
|
+
// share the same ref/context configuration (any divergence is already
|
|
240
|
+
// a schema bug).
|
|
241
|
+
for (const variantKey of ["oneOf", "anyOf", "allOf"] as const) {
|
|
242
|
+
const variants = node[variantKey];
|
|
243
|
+
if (!Array.isArray(variants)) continue;
|
|
244
|
+
for (const variant of variants) {
|
|
245
|
+
if (!variant || typeof variant !== "object") continue;
|
|
246
|
+
traverseVariant(variant as Record<string, any>, path, map, root, visitedRefs);
|
|
210
247
|
}
|
|
211
248
|
}
|
|
212
249
|
|
|
@@ -218,6 +255,46 @@ function traverseNode(node: Record<string, any>, path: string, map: ReferenceFie
|
|
|
218
255
|
typeof node.additionalProperties === "object" &&
|
|
219
256
|
!Array.isArray(node.additionalProperties)
|
|
220
257
|
) {
|
|
221
|
-
traverseNode(
|
|
258
|
+
traverseNode(
|
|
259
|
+
node.additionalProperties as Record<string, any>,
|
|
260
|
+
`${path}.{}`,
|
|
261
|
+
map,
|
|
262
|
+
root,
|
|
263
|
+
visitedRefs,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Walk a single variant of a `oneOf` / `anyOf` / `allOf` branch. Only
|
|
269
|
+
* the properties / items / map slots are followed — collectRefs at the
|
|
270
|
+
* variant root is handled by the parent's `collectRefs(node)` already
|
|
271
|
+
* (anyOf of x-telo-ref branches is the canonical multi-ref shape). */
|
|
272
|
+
function traverseVariant(
|
|
273
|
+
variant: Record<string, any>,
|
|
274
|
+
path: string,
|
|
275
|
+
map: ReferenceFieldMap,
|
|
276
|
+
root?: Record<string, any>,
|
|
277
|
+
visitedRefs: Set<string> = new Set(),
|
|
278
|
+
): void {
|
|
279
|
+
if (variant.properties) {
|
|
280
|
+
for (const [key, propSchema] of Object.entries(variant.properties)) {
|
|
281
|
+
traverseNode(propSchema as Record<string, any>, `${path}.${key}`, map, root, visitedRefs);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (variant.type === "array" && variant.items) {
|
|
285
|
+
traverseNode(variant.items as Record<string, any>, path + "[]", map, root, visitedRefs);
|
|
286
|
+
}
|
|
287
|
+
if (
|
|
288
|
+
variant.additionalProperties &&
|
|
289
|
+
typeof variant.additionalProperties === "object" &&
|
|
290
|
+
!Array.isArray(variant.additionalProperties)
|
|
291
|
+
) {
|
|
292
|
+
traverseNode(
|
|
293
|
+
variant.additionalProperties as Record<string, any>,
|
|
294
|
+
`${path}.{}`,
|
|
295
|
+
map,
|
|
296
|
+
root,
|
|
297
|
+
visitedRefs,
|
|
298
|
+
);
|
|
222
299
|
}
|
|
223
300
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { isRefSentinel } from "@telorun/templating";
|
|
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
|
+
import { REF_RESOLUTION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Walks every `x-telo-ref` slot in every non-system resource and rewrites
|
|
10
|
+
* `!ref <name>` sentinels in-place to `{kind: <resolved-kind>, name}`.
|
|
11
|
+
*
|
|
12
|
+
* The downstream pipeline (inline normalization, dependency graph, kernel
|
|
13
|
+
* controllers) expects every ref-slot value to be either a `{kind, name}`
|
|
14
|
+
* object, an inline-definition object, or a legacy bare string — resolving
|
|
15
|
+
* sentinels here keeps that contract intact so each consumer doesn't need
|
|
16
|
+
* its own sentinel branch.
|
|
17
|
+
*
|
|
18
|
+
* The walker assigns `kind` by name lookup (resource names are unique
|
|
19
|
+
* within a manifest scope). When the name doesn't resolve in the local
|
|
20
|
+
* `byName` map, the sentinel is left in place so `validateReferences`
|
|
21
|
+
* can emit the `UNRESOLVED_REFERENCE` diagnostic with full context.
|
|
22
|
+
*
|
|
23
|
+
* Mutation strategy: the field-path walker descends the resource tree
|
|
24
|
+
* directly and replaces the sentinel on its parent container. Re-parsing
|
|
25
|
+
* a string-encoded concrete path (the earlier shape) coupled the writer
|
|
26
|
+
* to the path-encoding rules of `resolveFieldEntries` — any new path
|
|
27
|
+
* marker would silently break this writer. Descending directly avoids
|
|
28
|
+
* that coupling.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveRefSentinels(
|
|
31
|
+
resources: ResourceManifest[],
|
|
32
|
+
registry: DefinitionRegistry,
|
|
33
|
+
aliases?: AliasResolver,
|
|
34
|
+
aliasesByModule?: Map<string, AliasResolver>,
|
|
35
|
+
): void {
|
|
36
|
+
const byName = new Map<string, ResourceManifest>();
|
|
37
|
+
for (const r of resources) {
|
|
38
|
+
if (r.metadata?.name && !SYSTEM_KINDS.has(r.kind)) {
|
|
39
|
+
byName.set(r.metadata.name as string, r);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const r of resources) {
|
|
44
|
+
if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
|
|
45
|
+
|
|
46
|
+
const fieldMap =
|
|
47
|
+
aliases && aliasesByModule
|
|
48
|
+
? registry.expandedFieldMapForResource(r, aliases, aliasesByModule)
|
|
49
|
+
: registry.getFieldMapForKind(r.kind, aliases);
|
|
50
|
+
if (!fieldMap) continue;
|
|
51
|
+
|
|
52
|
+
for (const [fieldPath, entry] of fieldMap) {
|
|
53
|
+
if (!isRefEntry(entry)) continue;
|
|
54
|
+
replaceSentinelsAtPath(r as Record<string, unknown>, fieldPath, byName);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Walks `obj` along `fieldPath` (dot notation with `[]` for arrays and
|
|
60
|
+
* `{}` for additionalProperties-typed maps) and replaces any `!ref`
|
|
61
|
+
* sentinel value at the terminal slot with `{kind, name}` looked up
|
|
62
|
+
* via `byName`. Mutates the parent container in place; no string-path
|
|
63
|
+
* round-trip. */
|
|
64
|
+
function replaceSentinelsAtPath(
|
|
65
|
+
obj: Record<string, unknown>,
|
|
66
|
+
fieldPath: string,
|
|
67
|
+
byName: Map<string, ResourceManifest>,
|
|
68
|
+
): void {
|
|
69
|
+
const parts = fieldPath.split(".");
|
|
70
|
+
descend(obj, parts, byName);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function descend(
|
|
74
|
+
obj: unknown,
|
|
75
|
+
parts: string[],
|
|
76
|
+
byName: Map<string, ResourceManifest>,
|
|
77
|
+
): void {
|
|
78
|
+
if (obj == null || typeof obj !== "object" || parts.length === 0) return;
|
|
79
|
+
const [head, ...rest] = parts;
|
|
80
|
+
|
|
81
|
+
// Map iteration: descend into every value of the current object.
|
|
82
|
+
if (head === "{}") {
|
|
83
|
+
const container = obj as Record<string, unknown>;
|
|
84
|
+
for (const key of Object.keys(container)) {
|
|
85
|
+
const child = container[key];
|
|
86
|
+
if (rest.length === 0) {
|
|
87
|
+
if (isRefSentinel(child)) {
|
|
88
|
+
const target = byName.get(child.source);
|
|
89
|
+
if (target) container[key] = { kind: target.kind as string, name: child.source };
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
descend(child, rest, byName);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const isArr = head.endsWith("[]");
|
|
99
|
+
const key = isArr ? head.slice(0, -2) : head;
|
|
100
|
+
const container = obj as Record<string, unknown>;
|
|
101
|
+
const val = container[key];
|
|
102
|
+
if (val == null) return;
|
|
103
|
+
|
|
104
|
+
if (isArr) {
|
|
105
|
+
if (!Array.isArray(val)) return;
|
|
106
|
+
for (let i = 0; i < val.length; i++) {
|
|
107
|
+
if (rest.length === 0) {
|
|
108
|
+
const elem = val[i];
|
|
109
|
+
if (isRefSentinel(elem)) {
|
|
110
|
+
const target = byName.get(elem.source);
|
|
111
|
+
if (target) val[i] = { kind: target.kind as string, name: elem.source };
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
descend(val[i], rest, byName);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
if (rest.length === 0) {
|
|
119
|
+
if (isRefSentinel(val)) {
|
|
120
|
+
const target = byName.get(val.source);
|
|
121
|
+
if (target) container[key] = { kind: target.kind as string, name: val.source };
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
descend(val, rest, byName);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
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
|
+
]);
|
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { isRefSentinel } from "@telorun/templating";
|
|
2
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`.
|
|
@@ -145,6 +141,38 @@ export function validateReferences(
|
|
|
145
141
|
for (const { value: val, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
|
|
146
142
|
if (!val) continue;
|
|
147
143
|
|
|
144
|
+
// `!ref <name>` sentinel — bare resource name marked at parse time as a
|
|
145
|
+
// reference. Look it up against the slot's x-telo-ref constraint exactly
|
|
146
|
+
// like the legacy bare-string path; the only difference is the value's
|
|
147
|
+
// shape (a TaggedSentinel rather than a raw string), which removed the
|
|
148
|
+
// string/inline ambiguity at the source.
|
|
149
|
+
if (isRefSentinel(val)) {
|
|
150
|
+
const refName = val.source;
|
|
151
|
+
const target =
|
|
152
|
+
byName.get(refName) ?? visibleScopeManifests.find((m) => m.metadata?.name === refName);
|
|
153
|
+
if (!target) {
|
|
154
|
+
diagnostics.push({
|
|
155
|
+
severity: DiagnosticSeverity.Error,
|
|
156
|
+
code: "UNRESOLVED_REFERENCE",
|
|
157
|
+
source: SOURCE,
|
|
158
|
+
message: `${resourceLabel}: reference at '${concretePath}' → resource '${refName}' not found`,
|
|
159
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
160
|
+
});
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const kindErrors = checkKind(target.kind as string, entry, registry, aliases);
|
|
164
|
+
if (kindErrors.length > 0) {
|
|
165
|
+
diagnostics.push({
|
|
166
|
+
severity: DiagnosticSeverity.Error,
|
|
167
|
+
code: "REFERENCE_KIND_MISMATCH",
|
|
168
|
+
source: SOURCE,
|
|
169
|
+
message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
|
|
170
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
148
176
|
// Name-only reference (plain string) — look up by name to validate.
|
|
149
177
|
// Qualified references use "Kind.Name" format (e.g. "Http.Api.PaymentApi");
|
|
150
178
|
// extract the resource name from the last dot segment.
|