@telorun/analyzer 0.13.0 → 0.15.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/alias-resolver.d.ts +6 -0
- package/dist/alias-resolver.d.ts.map +1 -1
- package/dist/alias-resolver.js +8 -0
- package/dist/analysis-registry.d.ts +1 -0
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analyzer.d.ts +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +111 -5
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +66 -0
- package/dist/flatten-for-analyzer.d.ts.map +1 -1
- package/dist/flatten-for-analyzer.js +17 -0
- package/dist/manifest-visitor.d.ts +15 -0
- package/dist/manifest-visitor.d.ts.map +1 -1
- package/dist/manifest-visitor.js +73 -2
- package/dist/reference-field-map.js +16 -0
- package/dist/resolve-ref-sentinels.d.ts +15 -17
- package/dist/resolve-ref-sentinels.d.ts.map +1 -1
- package/dist/resolve-ref-sentinels.js +86 -40
- package/dist/resolve-throws-union.d.ts +10 -0
- package/dist/resolve-throws-union.d.ts.map +1 -1
- package/dist/resolve-throws-union.js +35 -7
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +108 -7
- package/package.json +5 -4
- package/src/alias-resolver.ts +9 -0
- package/src/analysis-registry.ts +1 -1
- package/src/analyzer.ts +130 -5
- package/src/builtins.ts +66 -0
- package/src/flatten-for-analyzer.ts +20 -0
- package/src/manifest-visitor.ts +91 -2
- package/src/reference-field-map.ts +14 -0
- package/src/resolve-ref-sentinels.ts +89 -44
- package/src/resolve-throws-union.ts +36 -8
- package/src/validate-references.ts +110 -8
package/src/analyzer.ts
CHANGED
|
@@ -379,6 +379,78 @@ function buildStepContextSchema(
|
|
|
379
379
|
return undefined;
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Collect every field annotated with `x-telo-error-context` anywhere in a
|
|
384
|
+
* definition schema (resolving local `$ref`s into `$defs`, cycle-safe), mapping
|
|
385
|
+
* the annotated field name to its declared error-shape schema. The field name
|
|
386
|
+
* is matched against CEL paths so the context applies at any nesting depth under
|
|
387
|
+
* that field — e.g. `error` inside a `catch:` nested inside another `try:`. No
|
|
388
|
+
* specific field name (or `Run.Sequence`) is hardcoded; any composer that tags
|
|
389
|
+
* its error-bearing branch fields opts in the same way.
|
|
390
|
+
*/
|
|
391
|
+
function collectErrorContextScopes(
|
|
392
|
+
defSchema: Record<string, any> | undefined,
|
|
393
|
+
): Map<string, Record<string, any>> {
|
|
394
|
+
const out = new Map<string, Record<string, any>>();
|
|
395
|
+
if (!defSchema || typeof defSchema !== "object") return out;
|
|
396
|
+
const seen = new Set<Record<string, any>>();
|
|
397
|
+
|
|
398
|
+
const walk = (schema: Record<string, any> | undefined): void => {
|
|
399
|
+
if (!schema || typeof schema !== "object" || seen.has(schema)) return;
|
|
400
|
+
seen.add(schema);
|
|
401
|
+
|
|
402
|
+
const props = schema.properties as Record<string, any> | undefined;
|
|
403
|
+
if (props) {
|
|
404
|
+
for (const [fieldName, fieldSchema] of Object.entries(props)) {
|
|
405
|
+
if (fieldSchema && typeof fieldSchema === "object") {
|
|
406
|
+
const errCtx = (fieldSchema as Record<string, any>)["x-telo-error-context"];
|
|
407
|
+
if (errCtx && typeof errCtx === "object" && !out.has(fieldName)) {
|
|
408
|
+
out.set(fieldName, errCtx as Record<string, any>);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
walk(resolveLocalRef(fieldSchema as Record<string, any>, defSchema));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (schema.items) walk(resolveLocalRef(schema.items as Record<string, any>, defSchema));
|
|
415
|
+
for (const key of ["oneOf", "anyOf", "allOf"] as const) {
|
|
416
|
+
const arr = schema[key];
|
|
417
|
+
if (Array.isArray(arr)) for (const sub of arr) walk(resolveLocalRef(sub, defSchema));
|
|
418
|
+
}
|
|
419
|
+
if (schema.$defs && typeof schema.$defs === "object") {
|
|
420
|
+
for (const sub of Object.values(schema.$defs as Record<string, any>)) {
|
|
421
|
+
walk(sub as Record<string, any>);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
walk(defSchema);
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Return the error-context schema for a CEL `path` when the path lies within
|
|
432
|
+
* (any depth under) one of the error-bearing fields, else undefined. A path is
|
|
433
|
+
* "within" field `f` when it contains a segment `f[<index>]`. When multiple
|
|
434
|
+
* error-bearing fields match (e.g. a `finally` nested inside a `catch`), the
|
|
435
|
+
* deepest — the one whose segment appears latest in the path — wins, so the
|
|
436
|
+
* innermost branch's schema governs.
|
|
437
|
+
*/
|
|
438
|
+
function errorContextForPath(
|
|
439
|
+
path: string,
|
|
440
|
+
scopes: Map<string, Record<string, any>>,
|
|
441
|
+
): Record<string, any> | undefined {
|
|
442
|
+
let best: { index: number; schema: Record<string, any> } | undefined;
|
|
443
|
+
for (const [fieldName, schema] of scopes) {
|
|
444
|
+
const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
445
|
+
for (const match of path.matchAll(new RegExp(`(^|\\.)${escaped}\\[\\d+\\]`, "g"))) {
|
|
446
|
+
if (best === undefined || match.index > best.index) {
|
|
447
|
+
best = { index: match.index, schema };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return best?.schema;
|
|
452
|
+
}
|
|
453
|
+
|
|
382
454
|
const CEL_PURE_RE = /^\s*\$\{\{[^}]*\}\}\s*$/;
|
|
383
455
|
const CEL_EXPR_RE = /\$\{\{\s*([^}]+?)\s*\}\}/;
|
|
384
456
|
|
|
@@ -561,16 +633,19 @@ export class StaticAnalyzer {
|
|
|
561
633
|
// through the same alias machinery as user-declared Telo.Imports —
|
|
562
634
|
// honours the library's `exports.kinds` list, no special cases.
|
|
563
635
|
if (moduleName) {
|
|
564
|
-
|
|
636
|
+
// `Self` resolves the library's own kinds UNGATED — a library may reference
|
|
637
|
+
// its own kinds regardless of `exports.kinds`, which gates importers, not
|
|
638
|
+
// internal use. This is what lets a library declare an instance of a kind it
|
|
639
|
+
// does not export (e.g. console's `writeLine`) to enforce a singleton.
|
|
565
640
|
if (rootModules.has(moduleName)) {
|
|
566
|
-
aliases.registerImport("Self", moduleName,
|
|
641
|
+
aliases.registerImport("Self", moduleName, []);
|
|
567
642
|
} else {
|
|
568
643
|
let libResolver = aliasesByModule.get(moduleName);
|
|
569
644
|
if (!libResolver) {
|
|
570
645
|
libResolver = new AliasResolver();
|
|
571
646
|
aliasesByModule.set(moduleName, libResolver);
|
|
572
647
|
}
|
|
573
|
-
libResolver.registerImport("Self", moduleName,
|
|
648
|
+
libResolver.registerImport("Self", moduleName, []);
|
|
574
649
|
}
|
|
575
650
|
}
|
|
576
651
|
}
|
|
@@ -753,6 +828,15 @@ export class StaticAnalyzer {
|
|
|
753
828
|
continue;
|
|
754
829
|
}
|
|
755
830
|
|
|
831
|
+
// Forwarded exports (flagged by flattenForAnalyzer) are an imported library's exported
|
|
832
|
+
// instances, already validated in their own module's standalone analysis; their
|
|
833
|
+
// `kind`/CEL are authored in that module's scope (e.g. `Self.X` → that module, not the
|
|
834
|
+
// consumer). Re-validating against the consumer's scope yields false UNDEFINED_KIND /
|
|
835
|
+
// scope-mismatch errors, so skip — they participate here only as resolution targets.
|
|
836
|
+
if ((m.metadata as { forwardedExport?: boolean } | undefined)?.forwardedExport === true) {
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
|
|
756
840
|
const resource = { kind: m.kind, name: m.metadata?.name as string };
|
|
757
841
|
|
|
758
842
|
// Resolve kind through alias if needed; direct lookup takes priority so that
|
|
@@ -954,6 +1038,7 @@ export class StaticAnalyzer {
|
|
|
954
1038
|
// context — which require analyzer state to build — are stashed here.
|
|
955
1039
|
let celStepContextSchema: Record<string, any> | undefined;
|
|
956
1040
|
let celInvocationContext: Record<string, any> | undefined;
|
|
1041
|
+
let celErrorScopes: Map<string, Record<string, any>> = new Map();
|
|
957
1042
|
|
|
958
1043
|
visitManifest(
|
|
959
1044
|
allManifests,
|
|
@@ -973,6 +1058,9 @@ export class StaticAnalyzer {
|
|
|
973
1058
|
aliases,
|
|
974
1059
|
)
|
|
975
1060
|
: undefined;
|
|
1061
|
+
celErrorScopes = collectErrorContextScopes(
|
|
1062
|
+
e.definition?.schema as Record<string, any> | undefined,
|
|
1063
|
+
);
|
|
976
1064
|
},
|
|
977
1065
|
onCel: (e) => {
|
|
978
1066
|
const m = e.source;
|
|
@@ -995,6 +1083,22 @@ export class StaticAnalyzer {
|
|
|
995
1083
|
};
|
|
996
1084
|
}
|
|
997
1085
|
|
|
1086
|
+
// `error` is only in scope inside an error-bearing branch (e.g. a
|
|
1087
|
+
// `catch:` / `finally:`), so it's merged per-path, not resource-wide.
|
|
1088
|
+
const errorSchema =
|
|
1089
|
+
celErrorScopes.size > 0 ? errorContextForPath(path, celErrorScopes) : undefined;
|
|
1090
|
+
if (errorSchema) {
|
|
1091
|
+
const base =
|
|
1092
|
+
matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
|
|
1093
|
+
matchedContext = {
|
|
1094
|
+
...base,
|
|
1095
|
+
properties: {
|
|
1096
|
+
...(base.properties ?? {}),
|
|
1097
|
+
error: errorSchema,
|
|
1098
|
+
},
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
|
|
998
1102
|
let effectiveContext: Record<string, any> | null = null;
|
|
999
1103
|
if (matchedContext) {
|
|
1000
1104
|
const manifestItem = matchedScope
|
|
@@ -1046,6 +1150,14 @@ export class StaticAnalyzer {
|
|
|
1046
1150
|
message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
|
|
1047
1151
|
data: { resource, filePath, path },
|
|
1048
1152
|
});
|
|
1153
|
+
} else if (f.code === "CEL_NULLABLE_ACCESS") {
|
|
1154
|
+
diagnostics.push({
|
|
1155
|
+
severity: DiagnosticSeverity.Error,
|
|
1156
|
+
code: "CEL_NULLABLE_ACCESS",
|
|
1157
|
+
source: SOURCE,
|
|
1158
|
+
message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
|
|
1159
|
+
data: { resource, filePath, path },
|
|
1160
|
+
});
|
|
1049
1161
|
} else {
|
|
1050
1162
|
// Unknown code from a future engine — pass the message through,
|
|
1051
1163
|
// tagged with a generic ENGINE_DIAGNOSTIC code so downstream
|
|
@@ -1096,7 +1208,14 @@ export class StaticAnalyzer {
|
|
|
1096
1208
|
);
|
|
1097
1209
|
}
|
|
1098
1210
|
|
|
1099
|
-
normalize(
|
|
1211
|
+
normalize(
|
|
1212
|
+
manifests: ResourceManifest[],
|
|
1213
|
+
registry: AnalysisRegistry,
|
|
1214
|
+
// Forwarded foreign exports used only as cross-module resolution targets (see
|
|
1215
|
+
// resolveRefSentinels). The kernel passes its analyzer-flattened set so the
|
|
1216
|
+
// entry-only runtime pass can still resolve `!ref Alias.name`.
|
|
1217
|
+
crossModuleTargets?: ResourceManifest[],
|
|
1218
|
+
): ResourceManifest[] {
|
|
1100
1219
|
const ctx = registry._context();
|
|
1101
1220
|
const normalized = normalizeInlineResources(
|
|
1102
1221
|
manifests,
|
|
@@ -1107,7 +1226,13 @@ export class StaticAnalyzer {
|
|
|
1107
1226
|
// Resolve !ref sentinels after normalize so both the original and
|
|
1108
1227
|
// inline-extracted manifests get their refs canonicalized to
|
|
1109
1228
|
// {kind, name} for the kernel that consumes this output.
|
|
1110
|
-
resolveRefSentinels(
|
|
1229
|
+
resolveRefSentinels(
|
|
1230
|
+
normalized,
|
|
1231
|
+
ctx.definitions!,
|
|
1232
|
+
ctx.aliases,
|
|
1233
|
+
ctx.aliasesByModule,
|
|
1234
|
+
crossModuleTargets ?? [],
|
|
1235
|
+
);
|
|
1111
1236
|
return normalized;
|
|
1112
1237
|
}
|
|
1113
1238
|
|
package/src/builtins.ts
CHANGED
|
@@ -234,6 +234,66 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
|
|
|
234
234
|
},
|
|
235
235
|
additionalProperties: true,
|
|
236
236
|
},
|
|
237
|
+
// Gated reference: run() a Runnable/Service only when the
|
|
238
|
+
// `when` CEL guard holds. Discriminated by the `ref` key. `ref`
|
|
239
|
+
// is a bare name or a resolved `!ref` (`{ kind, name }`).
|
|
240
|
+
{
|
|
241
|
+
type: "object",
|
|
242
|
+
required: ["ref"],
|
|
243
|
+
properties: {
|
|
244
|
+
ref: {
|
|
245
|
+
anyOf: [
|
|
246
|
+
{ type: "string", "x-telo-ref": "telo#Runnable" },
|
|
247
|
+
{ type: "string", "x-telo-ref": "telo#Service" },
|
|
248
|
+
{
|
|
249
|
+
type: "object",
|
|
250
|
+
required: ["kind", "name"],
|
|
251
|
+
properties: {
|
|
252
|
+
kind: { type: "string" },
|
|
253
|
+
name: { type: "string" },
|
|
254
|
+
},
|
|
255
|
+
additionalProperties: true,
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
},
|
|
259
|
+
when: { type: "string" },
|
|
260
|
+
},
|
|
261
|
+
additionalProperties: false,
|
|
262
|
+
},
|
|
263
|
+
// Inline flat invoke step: invoke an Invocable / Runnable on boot
|
|
264
|
+
// with an optional `name` (for steps.<name>.result plumbing),
|
|
265
|
+
// `when` guard, and `inputs`. Discriminated by the `invoke` key.
|
|
266
|
+
// Control flow (if/while/switch/try) is not available here —
|
|
267
|
+
// reach for Run.Sequence. `invoke` is ref-only and must resolve
|
|
268
|
+
// to a `{ kind, name }` reference (a `!ref` / `{kind,name}`):
|
|
269
|
+
// requiring `name` rejects an inline `{ kind }` definition (no
|
|
270
|
+
// name) at analysis instead of failing at boot with an undefined
|
|
271
|
+
// resource name. The Invocable/Runnable kind set mirrors
|
|
272
|
+
// Run.Sequence invoke steps.
|
|
273
|
+
{
|
|
274
|
+
type: "object",
|
|
275
|
+
required: ["invoke"],
|
|
276
|
+
properties: {
|
|
277
|
+
name: { type: "string" },
|
|
278
|
+
invoke: {
|
|
279
|
+
"x-telo-topology-role": "invoke",
|
|
280
|
+
type: "object",
|
|
281
|
+
required: ["kind", "name"],
|
|
282
|
+
properties: {
|
|
283
|
+
kind: { type: "string" },
|
|
284
|
+
name: { type: "string" },
|
|
285
|
+
},
|
|
286
|
+
additionalProperties: true,
|
|
287
|
+
anyOf: [
|
|
288
|
+
{ "x-telo-ref": "telo#Invocable" },
|
|
289
|
+
{ "x-telo-ref": "telo#Runnable" },
|
|
290
|
+
],
|
|
291
|
+
},
|
|
292
|
+
inputs: { type: "object", additionalProperties: true },
|
|
293
|
+
when: { type: "string" },
|
|
294
|
+
},
|
|
295
|
+
additionalProperties: false,
|
|
296
|
+
},
|
|
237
297
|
],
|
|
238
298
|
},
|
|
239
299
|
},
|
|
@@ -338,6 +398,12 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
|
|
|
338
398
|
type: "object",
|
|
339
399
|
properties: {
|
|
340
400
|
kinds: { type: "array", items: { type: "string" } },
|
|
401
|
+
// `variables` / `secrets` are reserved on the resources.<Alias> value-flow
|
|
402
|
+
// surface, so a library may not export instances under those names.
|
|
403
|
+
resources: {
|
|
404
|
+
type: "array",
|
|
405
|
+
items: { type: "string", not: { enum: ["variables", "secrets"] } },
|
|
406
|
+
},
|
|
341
407
|
},
|
|
342
408
|
additionalProperties: true,
|
|
343
409
|
},
|
|
@@ -44,6 +44,10 @@ export function flattenForAnalyzer(graph: LoadedGraph): ResourceManifest[] {
|
|
|
44
44
|
if (!targetModule) continue;
|
|
45
45
|
|
|
46
46
|
const stamped = collectModuleManifests(targetModule);
|
|
47
|
+
const libDoc = stamped.find((m) => isModuleKind(m.kind));
|
|
48
|
+
const exportedResources = new Set<string>(
|
|
49
|
+
(libDoc as { exports?: { resources?: string[] } } | undefined)?.exports?.resources ?? [],
|
|
50
|
+
);
|
|
47
51
|
for (const m of stamped) {
|
|
48
52
|
if (
|
|
49
53
|
m.kind === "Telo.Definition" ||
|
|
@@ -51,6 +55,22 @@ export function flattenForAnalyzer(graph: LoadedGraph): ResourceManifest[] {
|
|
|
51
55
|
m.kind === "Telo.Import"
|
|
52
56
|
) {
|
|
53
57
|
result.push(m);
|
|
58
|
+
} else if (
|
|
59
|
+
!isModuleKind(m.kind) &&
|
|
60
|
+
typeof m.metadata?.name === "string" &&
|
|
61
|
+
exportedResources.has(m.metadata.name as string)
|
|
62
|
+
) {
|
|
63
|
+
// Forward instances listed in the library's `exports.resources` so the
|
|
64
|
+
// consumer's analyzer can resolve, gate, kind-check, and draw topology edges
|
|
65
|
+
// for cross-module `!ref Alias.name`. The gate IS this forwarding — only
|
|
66
|
+
// exported names cross the boundary. `metadata.forwardedExport` marks them as
|
|
67
|
+
// cross-module resolution targets only (keyed by `metadata.module`), so ref
|
|
68
|
+
// resolution / validation treats them as targets, never as local sources to
|
|
69
|
+
// re-validate or walk.
|
|
70
|
+
result.push({
|
|
71
|
+
...m,
|
|
72
|
+
metadata: { ...m.metadata, forwardedExport: true } as ResourceManifest["metadata"],
|
|
73
|
+
});
|
|
54
74
|
}
|
|
55
75
|
}
|
|
56
76
|
}
|
package/src/manifest-visitor.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
2
|
-
import { walkCelExpressions } from "@telorun/templating";
|
|
2
|
+
import { isRefSentinel, isTaggedSentinel, walkCelExpressions } from "@telorun/templating";
|
|
3
3
|
import type { AliasResolver } from "./alias-resolver.js";
|
|
4
4
|
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
5
5
|
import {
|
|
@@ -80,6 +80,13 @@ export interface RefSiteEvent {
|
|
|
80
80
|
inScope: boolean;
|
|
81
81
|
/** Scope manifests visible to this ref path (non-empty only when `inScope`). */
|
|
82
82
|
visibleScopeManifests: ResourceManifest[];
|
|
83
|
+
/** True when the site was found by value-tree scanning rather than the field
|
|
84
|
+
* map (only when `discoverNestedRefs` is set) — a ref nested behind a `$ref`
|
|
85
|
+
* the field map doesn't descend (e.g. `Run.Sequence` `steps[].invoke`).
|
|
86
|
+
* Nested sites carry no x-telo-ref constraint (`entry.refs` is empty) and no
|
|
87
|
+
* scope info; `concretePath` still points at the exact location, so consumers
|
|
88
|
+
* can anchor to it. */
|
|
89
|
+
nested?: boolean;
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
export interface SchemaFromSiteEvent {
|
|
@@ -123,6 +130,58 @@ export interface VisitOptions {
|
|
|
123
130
|
* so refs nested behind `x-telo-schema-from` are surfaced. `SchemaFromSite`
|
|
124
131
|
* events are always emitted from the base map regardless of this flag. */
|
|
125
132
|
expand?: boolean;
|
|
133
|
+
/** When true, additionally discover refs by scanning each resource's value
|
|
134
|
+
* tree for `!ref` sentinels and `{kind, name}` reference objects — surfacing
|
|
135
|
+
* refs the field map never lists because they sit behind a `$ref` it doesn't
|
|
136
|
+
* descend (notably `Run.Sequence` step `invoke`s). Emitted as `RefSite`s with
|
|
137
|
+
* `nested: true`, deduped against the field-map sites by concrete path.
|
|
138
|
+
* Opt-in: the validators / dependency graph must NOT enable it (those refs
|
|
139
|
+
* are runtime-resolved, not boot dependencies). */
|
|
140
|
+
discoverNestedRefs?: boolean;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Synthetic entry for a value-tree-discovered ref — these carry no declared
|
|
144
|
+
* x-telo-ref constraint. */
|
|
145
|
+
const NESTED_REF_ENTRY: RefFieldEntry = { refs: [], isArray: false };
|
|
146
|
+
|
|
147
|
+
/** Scans a value tree for ref-shaped values, emitting each with its concrete
|
|
148
|
+
* path. Recognizes `!ref <name>` sentinels and named `{kind, name}` reference
|
|
149
|
+
* objects. Other tagged sentinels (`!cel`, `!literal`) and precompiled nodes
|
|
150
|
+
* are leaves. Path format matches `resolveFieldEntries` / `walkCelExpressions`
|
|
151
|
+
* (`a.b[0].c`).
|
|
152
|
+
*
|
|
153
|
+
* Stops at every `{kind, …}` resource boundary: a named ref is emitted, an
|
|
154
|
+
* inline resource (`{kind}` with no name) is left alone, and **neither is
|
|
155
|
+
* descended into**. A nested resource's own refs belong to its inner topology,
|
|
156
|
+
* not the enclosing node — e.g. an inline `Sql.Exec` step's `connection` is the
|
|
157
|
+
* Exec's dependency, not the surrounding `Run.Sequence`'s. The scan is started
|
|
158
|
+
* per top-level field (not on the resource object) so the resource's own
|
|
159
|
+
* `kind` doesn't trip this boundary. */
|
|
160
|
+
function walkRefValues(
|
|
161
|
+
value: unknown,
|
|
162
|
+
path: string,
|
|
163
|
+
cb: (value: unknown, path: string) => void,
|
|
164
|
+
): void {
|
|
165
|
+
if (isRefSentinel(value)) {
|
|
166
|
+
cb(value, path);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (isTaggedSentinel(value)) return;
|
|
170
|
+
if (Array.isArray(value)) {
|
|
171
|
+
value.forEach((v, i) => walkRefValues(v, `${path}[${i}]`, cb));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (value === null || typeof value !== "object") return;
|
|
175
|
+
if ((value as { __compiled?: unknown }).__compiled) return;
|
|
176
|
+
const obj = value as Record<string, unknown>;
|
|
177
|
+
if (typeof obj.kind === "string") {
|
|
178
|
+
// Resource boundary — emit if it's a named ref, then stop descending.
|
|
179
|
+
if (typeof obj.name === "string") cb(value, path);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
183
|
+
walkRefValues(v, path ? `${path}.${k}` : k, cb);
|
|
184
|
+
}
|
|
126
185
|
}
|
|
127
186
|
|
|
128
187
|
const scopePrefixOf = (pointer: string): string =>
|
|
@@ -139,12 +198,13 @@ export function visitManifest(
|
|
|
139
198
|
visitor: ManifestVisitor,
|
|
140
199
|
options: VisitOptions = {},
|
|
141
200
|
): void {
|
|
142
|
-
const { aliases, aliasesByModule, skipKinds, expand } = options;
|
|
201
|
+
const { aliases, aliasesByModule, skipKinds, expand, discoverNestedRefs } = options;
|
|
143
202
|
|
|
144
203
|
const wantsRefs = !!visitor.onRef;
|
|
145
204
|
const wantsScope = !!visitor.onScope;
|
|
146
205
|
const wantsSchemaFrom = !!visitor.onSchemaFrom;
|
|
147
206
|
const wantsCel = !!visitor.onCel;
|
|
207
|
+
const wantsNested = wantsRefs && !!discoverNestedRefs;
|
|
148
208
|
|
|
149
209
|
for (const r of resources) {
|
|
150
210
|
if (!r.metadata?.name || !r.kind) continue;
|
|
@@ -157,6 +217,10 @@ export function visitManifest(
|
|
|
157
217
|
|
|
158
218
|
visitor.onResourceEnter?.({ source: r, definition });
|
|
159
219
|
|
|
220
|
+
// Concrete paths emitted from the field map — so the value-tree scan below
|
|
221
|
+
// doesn't re-emit a ref the field map already covered.
|
|
222
|
+
const emittedRefPaths = wantsNested ? new Set<string>() : null;
|
|
223
|
+
|
|
160
224
|
if (wantsRefs || wantsScope || wantsSchemaFrom) {
|
|
161
225
|
const baseMap = aliases
|
|
162
226
|
? registry.getFieldMapForKind(r.kind, aliases)
|
|
@@ -208,6 +272,7 @@ export function visitManifest(
|
|
|
208
272
|
|
|
209
273
|
for (const { value, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
|
|
210
274
|
if (!value) continue;
|
|
275
|
+
emittedRefPaths?.add(concretePath);
|
|
211
276
|
visitor.onRef!({
|
|
212
277
|
source: r,
|
|
213
278
|
fieldPath,
|
|
@@ -230,6 +295,30 @@ export function visitManifest(
|
|
|
230
295
|
}
|
|
231
296
|
}
|
|
232
297
|
|
|
298
|
+
// Value-tree-driven nested ref discovery — refs the field map can't reach
|
|
299
|
+
// because they sit behind a `$ref` it doesn't descend (e.g. Run.Sequence
|
|
300
|
+
// step `invoke`s). Deduped against the field-map sites by concrete path.
|
|
301
|
+
// Scanned per top-level field so the resource's own `kind` isn't treated as
|
|
302
|
+
// a resource boundary by `walkRefValues`.
|
|
303
|
+
if (wantsNested) {
|
|
304
|
+
const emitNested = (value: unknown, path: string) => {
|
|
305
|
+
if (emittedRefPaths!.has(path)) return;
|
|
306
|
+
visitor.onRef!({
|
|
307
|
+
source: r,
|
|
308
|
+
fieldPath: path,
|
|
309
|
+
concretePath: path,
|
|
310
|
+
value,
|
|
311
|
+
entry: NESTED_REF_ENTRY,
|
|
312
|
+
inScope: false,
|
|
313
|
+
visibleScopeManifests: [],
|
|
314
|
+
nested: true,
|
|
315
|
+
});
|
|
316
|
+
};
|
|
317
|
+
for (const [key, value] of Object.entries(r as Record<string, unknown>)) {
|
|
318
|
+
walkRefValues(value, key, emitNested);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
233
322
|
if (wantsCel) {
|
|
234
323
|
const contexts = definition?.schema ? extractContextsFromSchema(definition.schema) : [];
|
|
235
324
|
walkCelExpressions(r, "", (expr, path, engineName) => {
|
|
@@ -213,6 +213,20 @@ function traverseNode(
|
|
|
213
213
|
const entry: RefFieldEntry = { refs, isArray: path.includes("[]") };
|
|
214
214
|
if (node["x-telo-context"]) entry.context = node["x-telo-context"] as Record<string, any>;
|
|
215
215
|
map.set(path, entry);
|
|
216
|
+
// A node can mix item-level ref branches (a bare string / `{kind, name}`)
|
|
217
|
+
// with object branches that carry their OWN nested refs — e.g. Application
|
|
218
|
+
// `targets`: a bare ref vs inline `{ invoke }` vs gated `{ ref }`. Descend
|
|
219
|
+
// into the variant objects so those nested slots register too (and their
|
|
220
|
+
// `!ref` sentinels resolve). Pure x-telo-ref branches have no properties
|
|
221
|
+
// and contribute nothing here.
|
|
222
|
+
for (const variantKey of ["oneOf", "anyOf", "allOf"] as const) {
|
|
223
|
+
const variants = node[variantKey];
|
|
224
|
+
if (!Array.isArray(variants)) continue;
|
|
225
|
+
for (const variant of variants) {
|
|
226
|
+
if (!variant || typeof variant !== "object") continue;
|
|
227
|
+
traverseVariant(variant as Record<string, any>, path, map, root, visitedRefs);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
216
230
|
return;
|
|
217
231
|
}
|
|
218
232
|
|