@telorun/analyzer 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +50 -54
- package/dist/cel-environment.d.ts +3 -19
- package/dist/cel-environment.d.ts.map +1 -1
- package/dist/cel-environment.js +1 -40
- package/dist/manifest-loader.d.ts.map +1 -1
- package/dist/manifest-loader.js +3 -2
- package/dist/precompile.d.ts +11 -5
- package/dist/precompile.d.ts.map +1 -1
- package/dist/precompile.js +35 -35
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +4 -0
- package/dist/validate-cel-context.d.ts +1 -16
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +1 -133
- package/package.json +8 -4
- package/src/analyzer.ts +52 -65
- package/src/cel-environment.ts +3 -48
- package/src/manifest-loader.ts +3 -2
- package/src/precompile.ts +35 -39
- package/src/schema-compat.ts +4 -0
- package/src/validate-cel-context.ts +1 -142
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;
|
|
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;AAa9B,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA8W/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;IAmUvB,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;IAKxF,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;CAiB5F"}
|
package/dist/analyzer.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { defaultRegistry, walkCelExpressions } from "@telorun/templating";
|
|
1
2
|
import { AliasResolver } from "./alias-resolver.js";
|
|
2
3
|
import { buildCelEnvironment, buildTypedCelEnvironment, } from "./cel-environment.js";
|
|
3
4
|
import { DefinitionRegistry } from "./definition-registry.js";
|
|
@@ -8,11 +9,10 @@ import { isModuleKind } from "./module-kinds.js";
|
|
|
8
9
|
import { normalizeInlineResources } from "./normalize-inline-resources.js";
|
|
9
10
|
import { celTypeSatisfiesJsonSchema, substituteCelFields, validateAgainstSchema, } from "./schema-compat.js";
|
|
10
11
|
import { DiagnosticSeverity } from "./types.js";
|
|
11
|
-
import {
|
|
12
|
+
import { getManifestItem, pathMatchesScope, resolveContextAnnotations, resolveTypeFieldToSchema, } from "./validate-cel-context.js";
|
|
12
13
|
import { validateExtends } from "./validate-extends.js";
|
|
13
14
|
import { validateReferences } from "./validate-references.js";
|
|
14
15
|
import { validateThrowsCoverage } from "./validate-throws-coverage.js";
|
|
15
|
-
const TEMPLATE_REGEX = /\$\{\{\s*([^}]+?)\s*\}\}/g;
|
|
16
16
|
const SELF_PREFIX = "Self.";
|
|
17
17
|
/** Resolve an alias-prefixed kind value (e.g. `Self.Encoder` or `Ai.Model`)
|
|
18
18
|
* to its canonical form. `Self.<Name>` resolves to `<ownModule>.<Name>` —
|
|
@@ -36,21 +36,6 @@ function lookupDefinitionTypeField(invokedKind, fieldName, defs, aliases, allMan
|
|
|
36
36
|
const value = def[fieldName];
|
|
37
37
|
return resolveTypeFieldToSchema(value, allManifests);
|
|
38
38
|
}
|
|
39
|
-
function walkCelExpressions(value, path, cb) {
|
|
40
|
-
if (typeof value === "string") {
|
|
41
|
-
for (const m of value.matchAll(TEMPLATE_REGEX)) {
|
|
42
|
-
cb(m[1].trim(), path);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
else if (Array.isArray(value)) {
|
|
46
|
-
value.forEach((v, i) => walkCelExpressions(v, `${path}[${i}]`, cb));
|
|
47
|
-
}
|
|
48
|
-
else if (value !== null && typeof value === "object") {
|
|
49
|
-
for (const [k, v] of Object.entries(value)) {
|
|
50
|
-
walkCelExpressions(v, path ? `${path}.${k}` : k, cb);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
39
|
const SOURCE = "telo-analyzer";
|
|
55
40
|
/**
|
|
56
41
|
* Walk a JSON Schema tree and collect all `x-telo-context` annotations,
|
|
@@ -512,27 +497,14 @@ export class StaticAnalyzer {
|
|
|
512
497
|
const stepContextSchema = mDefinition?.schema
|
|
513
498
|
? buildStepContextSchema(m, mDefinition.schema, allManifests, defs, aliases)
|
|
514
499
|
: undefined;
|
|
515
|
-
walkCelExpressions(m, "", (expr, path) => {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
diagnostics.push({
|
|
522
|
-
severity: DiagnosticSeverity.Error,
|
|
523
|
-
code: "CEL_SYNTAX_ERROR",
|
|
524
|
-
source: SOURCE,
|
|
525
|
-
message: `CEL syntax error at ${path}: ${e instanceof Error ? e.message : String(e)}`,
|
|
526
|
-
data: { resource, filePath, path },
|
|
527
|
-
});
|
|
528
|
-
return;
|
|
529
|
-
}
|
|
530
|
-
const accessChains = extractAccessChains(parsed.ast);
|
|
500
|
+
walkCelExpressions(m, "", (expr, path, engineName) => {
|
|
501
|
+
// Resolve the effective context for this expression's path. The
|
|
502
|
+
// engine receives a single closed schema and validates member-access
|
|
503
|
+
// chains against it; per-path resolution (step context, x-telo-context,
|
|
504
|
+
// kernel-globals merge) stays on the analyzer side because it depends
|
|
505
|
+
// on analyzer-internal state (definitions, aliases).
|
|
531
506
|
const contexts = mDefinition?.schema ? extractContextsFromSchema(mDefinition.schema) : [];
|
|
532
507
|
const invocationContext = m.metadata?.xTeloInvocationContext;
|
|
533
|
-
// If no static context but we have step context, inject it
|
|
534
|
-
if (contexts.length === 0 && !invocationContext && !stepContextSchema)
|
|
535
|
-
return;
|
|
536
508
|
let matchedContext;
|
|
537
509
|
let matchedScope;
|
|
538
510
|
for (const ctx of contexts) {
|
|
@@ -544,7 +516,6 @@ export class StaticAnalyzer {
|
|
|
544
516
|
}
|
|
545
517
|
if (!matchedContext)
|
|
546
518
|
matchedContext = invocationContext;
|
|
547
|
-
// Merge step context into the effective context
|
|
548
519
|
if (stepContextSchema) {
|
|
549
520
|
const base = matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
|
|
550
521
|
matchedContext = {
|
|
@@ -555,24 +526,49 @@ export class StaticAnalyzer {
|
|
|
555
526
|
},
|
|
556
527
|
};
|
|
557
528
|
}
|
|
558
|
-
|
|
529
|
+
let effectiveContext = null;
|
|
530
|
+
if (matchedContext) {
|
|
531
|
+
const manifestItem = matchedScope
|
|
532
|
+
? getManifestItem(path, matchedScope, m)
|
|
533
|
+
: m;
|
|
534
|
+
const resolvedContext = resolveContextAnnotations(matchedContext, manifestItem, allManifests);
|
|
535
|
+
effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
|
|
536
|
+
}
|
|
537
|
+
const engine = defaultRegistry().get(engineName);
|
|
538
|
+
if (!engine)
|
|
559
539
|
return;
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
540
|
+
const findings = engine.analyze(expr, { celEnv: this.celEnv, contextSchema: effectiveContext });
|
|
541
|
+
for (const f of findings) {
|
|
542
|
+
if (f.code === "CEL_SYNTAX_ERROR") {
|
|
543
|
+
diagnostics.push({
|
|
544
|
+
severity: DiagnosticSeverity.Error,
|
|
545
|
+
code: "CEL_SYNTAX_ERROR",
|
|
546
|
+
source: SOURCE,
|
|
547
|
+
message: `CEL syntax error at ${path}: ${f.message}`,
|
|
548
|
+
data: { resource, filePath, path },
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
else if (f.code === "CEL_UNKNOWN_FIELD") {
|
|
552
|
+
diagnostics.push({
|
|
553
|
+
severity: DiagnosticSeverity.Error,
|
|
554
|
+
code: "CEL_UNKNOWN_FIELD",
|
|
555
|
+
source: SOURCE,
|
|
556
|
+
message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
|
|
557
|
+
data: { resource, filePath, path },
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
// Unknown code from a future engine — pass the message through,
|
|
562
|
+
// tagged with a generic ENGINE_DIAGNOSTIC code so downstream
|
|
563
|
+
// filters can still bucket it.
|
|
564
|
+
diagnostics.push({
|
|
565
|
+
severity: DiagnosticSeverity.Error,
|
|
566
|
+
code: f.code ?? "ENGINE_DIAGNOSTIC",
|
|
567
|
+
source: SOURCE,
|
|
568
|
+
message: `${m.kind}/${resource.name}: !${engineName} at '${path}': ${f.message}`,
|
|
569
|
+
data: { resource, filePath, path },
|
|
570
|
+
});
|
|
571
|
+
}
|
|
576
572
|
}
|
|
577
573
|
});
|
|
578
574
|
}
|
|
@@ -1,23 +1,7 @@
|
|
|
1
1
|
import { Environment } from "@marcbachmann/cel-js";
|
|
2
|
-
import {
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
json: (value: unknown) => string;
|
|
6
|
-
}
|
|
7
|
-
/** Build a CEL `Environment` with Telo's stdlib of functions. Always registers the
|
|
8
|
-
* same function signatures (so `env.check()` succeeds for type-inference) — the
|
|
9
|
-
* handlers govern what the function does when called at runtime. Analyzer-only
|
|
10
|
-
* callers can omit handlers; runtime callers (kernel) must supply real ones.
|
|
11
|
-
*
|
|
12
|
-
* Also registers the `Stream` object type, backed by the `Stream` class from
|
|
13
|
-
* `@telorun/sdk`. CEL's type-checker rejects values whose constructor isn't
|
|
14
|
-
* Object/Map/Array/Set/registered; producers that need to expose an
|
|
15
|
-
* `AsyncIterable` through a stream-typed property must wrap the iterable in
|
|
16
|
-
* `new Stream(...)` so its constructor is the registered class. The type has
|
|
17
|
-
* no fields, so terminal access (passing the value through CEL) succeeds but
|
|
18
|
-
* member access raises a CEL error at runtime — matching the analyzer's
|
|
19
|
-
* static check on `x-telo-stream`-marked properties. */
|
|
20
|
-
export declare function buildCelEnvironment(handlers?: CelHandlers): Environment;
|
|
2
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
3
|
+
export { buildCelEnvironment } from "@telorun/templating";
|
|
4
|
+
export type { CelHandlers } from "@telorun/templating";
|
|
21
5
|
/** Clone `baseEnv` and register typed variable declarations so that
|
|
22
6
|
* `env.check(expr)` can infer return types for expressions referencing known variables.
|
|
23
7
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cel-environment.d.ts","sourceRoot":"","sources":["../src/cel-environment.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACnD,OAAO,
|
|
1
|
+
{"version":3,"file":"cel-environment.d.ts","sourceRoot":"","sources":["../src/cel-environment.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACnD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAGrD,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC1D,YAAY,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAEvD;;;;;;;;;uEASuE;AACvE,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,WAAW,EACpB,QAAQ,EAAE,gBAAgB,EAC1B,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,IAAI,GAC9C,WAAW,CAyCb"}
|
package/dist/cel-environment.js
CHANGED
|
@@ -1,44 +1,5 @@
|
|
|
1
|
-
import { Environment } from "@marcbachmann/cel-js";
|
|
2
|
-
import { Stream } from "@telorun/sdk";
|
|
3
1
|
import { jsonSchemaToCelType } from "./schema-compat.js";
|
|
4
|
-
|
|
5
|
-
throw new Error(`${name}() is not available in this environment. ` +
|
|
6
|
-
`Construct StaticAnalyzer or Loader with celHandlers to enable it.`);
|
|
7
|
-
};
|
|
8
|
-
const STUB_HANDLERS = {
|
|
9
|
-
sha256: stub("sha256"),
|
|
10
|
-
json: stub("json"),
|
|
11
|
-
};
|
|
12
|
-
/** Build a CEL `Environment` with Telo's stdlib of functions. Always registers the
|
|
13
|
-
* same function signatures (so `env.check()` succeeds for type-inference) — the
|
|
14
|
-
* handlers govern what the function does when called at runtime. Analyzer-only
|
|
15
|
-
* callers can omit handlers; runtime callers (kernel) must supply real ones.
|
|
16
|
-
*
|
|
17
|
-
* Also registers the `Stream` object type, backed by the `Stream` class from
|
|
18
|
-
* `@telorun/sdk`. CEL's type-checker rejects values whose constructor isn't
|
|
19
|
-
* Object/Map/Array/Set/registered; producers that need to expose an
|
|
20
|
-
* `AsyncIterable` through a stream-typed property must wrap the iterable in
|
|
21
|
-
* `new Stream(...)` so its constructor is the registered class. The type has
|
|
22
|
-
* no fields, so terminal access (passing the value through CEL) succeeds but
|
|
23
|
-
* member access raises a CEL error at runtime — matching the analyzer's
|
|
24
|
-
* static check on `x-telo-stream`-marked properties. */
|
|
25
|
-
export function buildCelEnvironment(handlers = STUB_HANDLERS) {
|
|
26
|
-
return new Environment({ unlistedVariablesAreDyn: true, enableOptionalTypes: true })
|
|
27
|
-
.registerFunction("join(list, string): string", (list, sep) => list.map(String).join(sep))
|
|
28
|
-
.registerFunction("keys(map): list", (map) => {
|
|
29
|
-
if (map instanceof Map)
|
|
30
|
-
return [...map.keys()];
|
|
31
|
-
return Object.keys(map);
|
|
32
|
-
})
|
|
33
|
-
.registerFunction("values(map): list", (map) => {
|
|
34
|
-
if (map instanceof Map)
|
|
35
|
-
return [...map.values()];
|
|
36
|
-
return Object.values(map);
|
|
37
|
-
})
|
|
38
|
-
.registerFunction("sha256(string): string", (s) => handlers.sha256(s))
|
|
39
|
-
.registerFunction("json(dyn): string", (value) => handlers.json(value))
|
|
40
|
-
.registerType("Stream", Stream);
|
|
41
|
-
}
|
|
2
|
+
export { buildCelEnvironment } from "@telorun/templating";
|
|
42
3
|
/** Clone `baseEnv` and register typed variable declarations so that
|
|
43
4
|
* `env.check(expr)` can infer return types for expressions referencing known variables.
|
|
44
5
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"manifest-loader.d.ts","sourceRoot":"","sources":["../src/manifest-loader.ts"],"names":[],"mappings":"AACA,OAAO,EAAmB,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"manifest-loader.d.ts","sourceRoot":"","sources":["../src/manifest-loader.ts"],"names":[],"mappings":"AACA,OAAO,EAAmB,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAQtE,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACtB,KAAK,cAAc,EAGpB,MAAM,YAAY,CAAC;AASpB,qBAAa,MAAM;IACjB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAGxB;IAEJ,SAAS,CAAC,OAAO,EAAE,cAAc,EAAE,CAAC;IACpC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,qBAAqB,GAAE,cAAc,EAAE,GAAG,iBAAsB;IAmB5E,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI;IAKtC,OAAO,CAAC,IAAI;IAMN,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAK/C,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;YAqGnE,eAAe;YAoBf,eAAe;IAgEvB,iBAAiB,CACrB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC;QACT,QAAQ,EAAE,MAAM,CAAC;QACjB,SAAS,EAAE,gBAAgB,EAAE,CAAC;QAC9B,eAAe,EAAE,GAAG,CAAC,MAAM,EAAE,gBAAgB,EAAE,CAAC,CAAC;KAClD,GAAG,IAAI,CAAC;IAiCH,eAAe,CACnB,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,KAAK,IAAI,GAC5C,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,gBAAgB,EAAE,CAAC,CAAC;IAsCrC,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;CA+HnE"}
|
package/dist/manifest-loader.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isCompiledValue } from "@telorun/sdk";
|
|
2
|
+
import { defaultCustomTags } from "@telorun/templating";
|
|
2
3
|
import { isMap, isPair, isScalar, isSeq, parseAllDocuments } from "yaml";
|
|
3
4
|
import { HttpSource } from "./sources/http-source.js";
|
|
4
5
|
import { RegistrySource } from "./sources/registry-source.js";
|
|
@@ -53,7 +54,7 @@ export class Loader {
|
|
|
53
54
|
if (cached && cached.text === text) {
|
|
54
55
|
return cloneManifestArray(cached.manifests);
|
|
55
56
|
}
|
|
56
|
-
const parsedDocuments = parseAllDocuments(text);
|
|
57
|
+
const parsedDocuments = parseAllDocuments(text, { customTags: defaultCustomTags() });
|
|
57
58
|
const rawDocs = parsedDocuments.map((d) => d.toJSON());
|
|
58
59
|
const offsets = documentLineOffsets(text);
|
|
59
60
|
const lineOffsets = buildLineOffsets(text);
|
|
@@ -154,7 +155,7 @@ export class Loader {
|
|
|
154
155
|
}
|
|
155
156
|
async loadPartialFile(url, ownerModuleName, options) {
|
|
156
157
|
const { text, source } = await this.pick(url).read(url);
|
|
157
|
-
const parsedDocuments = parseAllDocuments(text);
|
|
158
|
+
const parsedDocuments = parseAllDocuments(text, { customTags: defaultCustomTags() });
|
|
158
159
|
const rawDocs = parsedDocuments.map((d) => d.toJSON());
|
|
159
160
|
const offsets = documentLineOffsets(text);
|
|
160
161
|
const lineOffsets = buildLineOffsets(text);
|
package/dist/precompile.d.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import type { Environment } from "@marcbachmann/cel-js";
|
|
2
2
|
/**
|
|
3
|
-
* Walks a raw YAML document and replaces all
|
|
4
|
-
* CompiledValue wrappers. Throws on CEL syntax
|
|
5
|
-
* Intended to be called once per document at load time.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* Walks a raw YAML document and replaces all `${{ expr }}` strings (and
|
|
4
|
+
* `!cel`-tagged sentinels) with CompiledValue wrappers. Throws on CEL syntax
|
|
5
|
+
* errors. Intended to be called once per document at load time.
|
|
6
|
+
*
|
|
7
|
+
* Note on Telo.Definition / Telo.Abstract: the walker traverses these too.
|
|
8
|
+
* Their `schema` fields are JSON Schema metadata and don't typically contain
|
|
9
|
+
* `${{ }}` text, so compile is a no-op there. Their `template` fields, on
|
|
10
|
+
* the other hand, *do* carry CEL — Definition-driven templates are expanded
|
|
11
|
+
* by the kernel and rely on the precompiled tree. If a description or
|
|
12
|
+
* example string inside a schema happens to contain `${{ }}`, it will be
|
|
13
|
+
* interpreted as CEL; tag it `!literal` to opt out.
|
|
8
14
|
*/
|
|
9
15
|
export declare function precompileDoc(doc: unknown, env: Environment): unknown;
|
|
10
16
|
//# sourceMappingURL=precompile.d.ts.map
|
package/dist/precompile.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"precompile.d.ts","sourceRoot":"","sources":["../src/precompile.ts"],"names":[],"mappings":"
|
|
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,CAmCrE"}
|
package/dist/precompile.js
CHANGED
|
@@ -1,13 +1,41 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { isCompiledValue } from "@telorun/sdk";
|
|
2
|
+
import { compileString, defaultRegistry, isTaggedSentinel } from "@telorun/templating";
|
|
3
3
|
/**
|
|
4
|
-
* Walks a raw YAML document and replaces all
|
|
5
|
-
* CompiledValue wrappers. Throws on CEL syntax
|
|
6
|
-
* Intended to be called once per document at load time.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Walks a raw YAML document and replaces all `${{ expr }}` strings (and
|
|
5
|
+
* `!cel`-tagged sentinels) with CompiledValue wrappers. Throws on CEL syntax
|
|
6
|
+
* errors. Intended to be called once per document at load time.
|
|
7
|
+
*
|
|
8
|
+
* Note on Telo.Definition / Telo.Abstract: the walker traverses these too.
|
|
9
|
+
* Their `schema` fields are JSON Schema metadata and don't typically contain
|
|
10
|
+
* `${{ }}` text, so compile is a no-op there. Their `template` fields, on
|
|
11
|
+
* the other hand, *do* carry CEL — Definition-driven templates are expanded
|
|
12
|
+
* by the kernel and rely on the precompiled tree. If a description or
|
|
13
|
+
* example string inside a schema happens to contain `${{ }}`, it will be
|
|
14
|
+
* interpreted as CEL; tag it `!literal` to opt out.
|
|
9
15
|
*/
|
|
10
16
|
export function precompileDoc(doc, env) {
|
|
17
|
+
// Tagged sentinel: dispatch to the engine. The result is decorated with
|
|
18
|
+
// `__tagged` + `engine` + `source` when it's a CompiledValue so the
|
|
19
|
+
// analyzer's diagnostic walk can identify it on compiled trees too;
|
|
20
|
+
// engines returning plain values (e.g. `literal` → a string) pass through
|
|
21
|
+
// verbatim — the runtime contract is "any scalar value is fine."
|
|
22
|
+
if (isTaggedSentinel(doc)) {
|
|
23
|
+
const engine = defaultRegistry().get(doc.engine);
|
|
24
|
+
if (!engine) {
|
|
25
|
+
throw new Error(`Unknown templating engine: !${doc.engine}`);
|
|
26
|
+
}
|
|
27
|
+
const compiled = engine.compile(doc.source, { celEnv: env });
|
|
28
|
+
if (isCompiledValue(compiled)) {
|
|
29
|
+
return {
|
|
30
|
+
__tagged: true,
|
|
31
|
+
__compiled: true,
|
|
32
|
+
engine: doc.engine,
|
|
33
|
+
source: doc.source,
|
|
34
|
+
call: compiled.call.bind(compiled),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return compiled;
|
|
38
|
+
}
|
|
11
39
|
if (typeof doc === "string")
|
|
12
40
|
return compileString(doc, env);
|
|
13
41
|
if (Array.isArray(doc))
|
|
@@ -23,31 +51,3 @@ export function precompileDoc(doc, env) {
|
|
|
23
51
|
}
|
|
24
52
|
return doc;
|
|
25
53
|
}
|
|
26
|
-
function compileString(s, env) {
|
|
27
|
-
if (!s.includes("${{"))
|
|
28
|
-
return s;
|
|
29
|
-
const exact = s.match(EXACT_TEMPLATE_REGEX);
|
|
30
|
-
if (exact) {
|
|
31
|
-
const expr = exact[1].trim();
|
|
32
|
-
const fn = env.parse(expr);
|
|
33
|
-
return { __compiled: true, source: expr, call: (ctx) => fn(ctx) };
|
|
34
|
-
}
|
|
35
|
-
// Interpolated template — collect literal parts + compiled sub-expressions
|
|
36
|
-
const parts = [];
|
|
37
|
-
let last = 0;
|
|
38
|
-
for (const m of s.matchAll(TEMPLATE_REGEX)) {
|
|
39
|
-
if (m.index > last)
|
|
40
|
-
parts.push(s.slice(last, m.index));
|
|
41
|
-
const expr = m[1].trim();
|
|
42
|
-
const fn = env.parse(expr);
|
|
43
|
-
parts.push({ __compiled: true, source: expr, call: (ctx) => fn(ctx) });
|
|
44
|
-
last = m.index + m[0].length;
|
|
45
|
-
}
|
|
46
|
-
if (last < s.length)
|
|
47
|
-
parts.push(s.slice(last));
|
|
48
|
-
return {
|
|
49
|
-
__compiled: true,
|
|
50
|
-
source: s,
|
|
51
|
-
call: (ctx) => parts.map((p) => (typeof p === "string" ? p : String(p.call(ctx) ?? ""))).join(""),
|
|
52
|
-
};
|
|
53
|
-
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema-compat.d.ts","sourceRoot":"","sources":["../src/schema-compat.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"schema-compat.d.ts","sourceRoot":"","sources":["../src/schema-compat.ts"],"names":[],"mappings":"AAIA,QAAA,MAAM,GAAG,KAA0C,CAAC;AAEpD;0FAC0F;AAC1F,wBAAgB,SAAS,IAAI,YAAY,CAAC,OAAO,GAAG,CAAC,CAMpD;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,CA2BT"}
|
package/dist/schema-compat.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import AjvModule from "ajv";
|
|
2
2
|
import addFormats from "ajv-formats";
|
|
3
|
+
import { isTaggedSentinel } from "@telorun/templating";
|
|
3
4
|
const Ajv = AjvModule.default ?? AjvModule;
|
|
4
5
|
/** Creates a configured AJV instance (allErrors, strict: false, with formats).
|
|
5
6
|
* Called once for the module-level instance and once per DefinitionRegistry instance. */
|
|
@@ -269,6 +270,9 @@ export function substituteCelFields(data, schema, rootSchema) {
|
|
|
269
270
|
if (typeof data === "string" && CEL_PURE_RE.test(data)) {
|
|
270
271
|
return celPlaceholderForSchema(resolved);
|
|
271
272
|
}
|
|
273
|
+
if (isTaggedSentinel(data)) {
|
|
274
|
+
return celPlaceholderForSchema(resolved);
|
|
275
|
+
}
|
|
272
276
|
if (Array.isArray(data)) {
|
|
273
277
|
const itemSchema = resolveRef((resolved.items ?? {}), root);
|
|
274
278
|
return data.map((item) => substituteCelFields(item, itemSchema, root));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
export { extractAccessChains, validateChainAgainstSchema } from "@telorun/templating";
|
|
2
2
|
/**
|
|
3
3
|
* Resolve a type field value (string name, inline type, or raw schema) to a JSON Schema.
|
|
4
4
|
* - String: look up the named type in allManifests (Type.JsonSchema resources)
|
|
@@ -6,21 +6,6 @@ import type { ASTNode } from "@marcbachmann/cel-js";
|
|
|
6
6
|
* - Object with `type` or `properties`: raw JSON Schema, return as-is
|
|
7
7
|
*/
|
|
8
8
|
export declare function resolveTypeFieldToSchema(value: unknown, allManifests: Record<string, any>[]): Record<string, any> | undefined;
|
|
9
|
-
/**
|
|
10
|
-
* Extract all member-access chains from a CEL AST.
|
|
11
|
-
* Returns arrays like ["request", "query", "name"] for `request.query.name`.
|
|
12
|
-
* Chains that start with a call or non-identifier root are ignored.
|
|
13
|
-
* Bound variables in comprehension macros (filter, map, exists, all, exists_one) are excluded.
|
|
14
|
-
*/
|
|
15
|
-
export declare function extractAccessChains(node: ASTNode): string[][];
|
|
16
|
-
/**
|
|
17
|
-
* Check whether a member-access chain accesses only fields declared in a JSON Schema.
|
|
18
|
-
* Returns an error string if a field is unknown in a schema that declares explicit
|
|
19
|
-
* properties without `additionalProperties: true`, or if the chain attempts to
|
|
20
|
-
* reach inside an `x-telo-stream: true` property.
|
|
21
|
-
* Returns null when the chain is valid or the schema is too open to judge.
|
|
22
|
-
*/
|
|
23
|
-
export declare function validateChainAgainstSchema(chain: string[], schema: Record<string, any>): string | null;
|
|
24
9
|
/**
|
|
25
10
|
* Returns true when a CEL expression path (from walkCelExpressions, e.g. "routes[0].inputs.q")
|
|
26
11
|
* falls within the scope of a context (e.g. "$.routes[*].inputs").
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validate-cel-context.d.ts","sourceRoot":"","sources":["../src/validate-cel-context.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"validate-cel-context.d.ts","sourceRoot":"","sources":["../src/validate-cel-context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,0BAA0B,EAAE,MAAM,qBAAqB,CAAC;AAEtF;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,OAAO,EACd,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GAClC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CA6BjC;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAoBzE;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EACjC,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GACnC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAqDrB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC5B,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAQrB"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { extractAccessChains, validateChainAgainstSchema } from "@telorun/templating";
|
|
1
2
|
/**
|
|
2
3
|
* Resolve a type field value (string name, inline type, or raw schema) to a JSON Schema.
|
|
3
4
|
* - String: look up the named type in allManifests (Type.JsonSchema resources)
|
|
@@ -29,139 +30,6 @@ export function resolveTypeFieldToSchema(value, allManifests) {
|
|
|
29
30
|
}
|
|
30
31
|
return undefined;
|
|
31
32
|
}
|
|
32
|
-
/**
|
|
33
|
-
* Extract all member-access chains from a CEL AST.
|
|
34
|
-
* Returns arrays like ["request", "query", "name"] for `request.query.name`.
|
|
35
|
-
* Chains that start with a call or non-identifier root are ignored.
|
|
36
|
-
* Bound variables in comprehension macros (filter, map, exists, all, exists_one) are excluded.
|
|
37
|
-
*/
|
|
38
|
-
export function extractAccessChains(node) {
|
|
39
|
-
const chains = [];
|
|
40
|
-
visitNode(node, chains, new Set());
|
|
41
|
-
return chains;
|
|
42
|
-
}
|
|
43
|
-
// CEL comprehension macros that bind a variable: list.filter(x, ...), list.map(x, ...), etc.
|
|
44
|
-
const COMPREHENSION_METHODS = new Set(["filter", "map", "exists", "all", "exists_one"]);
|
|
45
|
-
function visitNode(node, chains, boundVars) {
|
|
46
|
-
const chain = extractChain(node, boundVars);
|
|
47
|
-
if (chain !== null) {
|
|
48
|
-
chains.push(chain);
|
|
49
|
-
return; // don't recurse into parts of an already-collected chain
|
|
50
|
-
}
|
|
51
|
-
// Comprehension macros bind a variable in their body — handle them specially
|
|
52
|
-
// AST shape: { op: "rcall", args: [methodName, receiver, [boundVarId, body, ...]] }
|
|
53
|
-
if (node.op === "rcall" &&
|
|
54
|
-
Array.isArray(node.args) &&
|
|
55
|
-
typeof node.args[0] === "string" &&
|
|
56
|
-
COMPREHENSION_METHODS.has(node.args[0])) {
|
|
57
|
-
const receiver = node.args[1];
|
|
58
|
-
const comprehensionArgs = node.args[2];
|
|
59
|
-
if (isASTNode(receiver))
|
|
60
|
-
visitNode(receiver, chains, boundVars);
|
|
61
|
-
if (Array.isArray(comprehensionArgs) &&
|
|
62
|
-
comprehensionArgs.length >= 2 &&
|
|
63
|
-
isASTNode(comprehensionArgs[0]) &&
|
|
64
|
-
comprehensionArgs[0].op === "id") {
|
|
65
|
-
const newBoundVars = new Set(boundVars);
|
|
66
|
-
newBoundVars.add(comprehensionArgs[0].args);
|
|
67
|
-
for (let i = 1; i < comprehensionArgs.length; i++) {
|
|
68
|
-
const arg = comprehensionArgs[i];
|
|
69
|
-
if (isASTNode(arg))
|
|
70
|
-
visitNode(arg, chains, newBoundVars);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
const args = node.args;
|
|
76
|
-
if (Array.isArray(args)) {
|
|
77
|
-
for (const arg of args) {
|
|
78
|
-
if (isASTNode(arg)) {
|
|
79
|
-
visitNode(arg, chains, boundVars);
|
|
80
|
-
}
|
|
81
|
-
else if (Array.isArray(arg)) {
|
|
82
|
-
for (const item of arg) {
|
|
83
|
-
if (isASTNode(item))
|
|
84
|
-
visitNode(item, chains, boundVars);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
function isASTNode(v) {
|
|
91
|
-
return v !== null && typeof v === "object" && "op" in v;
|
|
92
|
-
}
|
|
93
|
-
// Sentinel used in extracted chains to represent an `[index]` access. The
|
|
94
|
-
// validator treats it like any other deeper segment, so a chain that reaches
|
|
95
|
-
// past an `x-telo-stream`-marked property via `[N]` is rejected the same way
|
|
96
|
-
// `.field` access is. The actual index expression is opaque to the chain
|
|
97
|
-
// validator (it only checks property names against the schema).
|
|
98
|
-
const INDEX_SEGMENT = "[*]";
|
|
99
|
-
/** Returns the member-access chain for a node if it is purely an id, ".", or
|
|
100
|
-
* "[]" chain; else null. Bracket index access is captured as `INDEX_SEGMENT`
|
|
101
|
-
* so it counts as a chain extension for the stream-property opacity rule. */
|
|
102
|
-
function extractChain(node, boundVars) {
|
|
103
|
-
if (node.op === "id") {
|
|
104
|
-
const name = node.args;
|
|
105
|
-
if (boundVars.has(name))
|
|
106
|
-
return null; // bound by a comprehension macro, not a free access
|
|
107
|
-
return [name];
|
|
108
|
-
}
|
|
109
|
-
if (node.op === ".") {
|
|
110
|
-
const [obj, field] = node.args;
|
|
111
|
-
const parent = extractChain(obj, boundVars);
|
|
112
|
-
if (parent !== null)
|
|
113
|
-
return [...parent, field];
|
|
114
|
-
}
|
|
115
|
-
if (node.op === "[]") {
|
|
116
|
-
const [obj] = node.args;
|
|
117
|
-
const parent = extractChain(obj, boundVars);
|
|
118
|
-
if (parent !== null)
|
|
119
|
-
return [...parent, INDEX_SEGMENT];
|
|
120
|
-
}
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
/**
|
|
124
|
-
* Check whether a member-access chain accesses only fields declared in a JSON Schema.
|
|
125
|
-
* Returns an error string if a field is unknown in a schema that declares explicit
|
|
126
|
-
* properties without `additionalProperties: true`, or if the chain attempts to
|
|
127
|
-
* reach inside an `x-telo-stream: true` property.
|
|
128
|
-
* Returns null when the chain is valid or the schema is too open to judge.
|
|
129
|
-
*/
|
|
130
|
-
export function validateChainAgainstSchema(chain, schema) {
|
|
131
|
-
let current = schema;
|
|
132
|
-
for (let i = 0; i < chain.length; i++) {
|
|
133
|
-
const key = chain[i];
|
|
134
|
-
if (!current || typeof current !== "object")
|
|
135
|
-
return null;
|
|
136
|
-
const props = current.properties;
|
|
137
|
-
if (!props)
|
|
138
|
-
return null;
|
|
139
|
-
if (key in props) {
|
|
140
|
-
const propSchema = props[key];
|
|
141
|
-
// Stream-typed properties are opaque: pass through by reference, no
|
|
142
|
-
// member access inside. Reaching `result.output` is fine; reaching
|
|
143
|
-
// `result.output.text` is a wiring mistake — the consumer either iterates
|
|
144
|
-
// the stream in a JS.Script step or pipes it through an Encoder.
|
|
145
|
-
if (propSchema &&
|
|
146
|
-
typeof propSchema === "object" &&
|
|
147
|
-
propSchema["x-telo-stream"] === true &&
|
|
148
|
-
i < chain.length - 1) {
|
|
149
|
-
const path = chain.slice(0, i + 1).join(".");
|
|
150
|
-
return `'${path}' yields a stream — pipe it through an Encoder or iterate in a JS.Script step (no member access on stream-typed values)`;
|
|
151
|
-
}
|
|
152
|
-
// Known property — drill into it even if additionalProperties is true
|
|
153
|
-
current = propSchema;
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
// Unknown property — only flag if schema is closed
|
|
157
|
-
if (current.additionalProperties === true)
|
|
158
|
-
return null;
|
|
159
|
-
const path = chain.slice(0, i + 1).join(".");
|
|
160
|
-
const available = Object.keys(props).join(", ");
|
|
161
|
-
return `'${path}' is not defined (available: ${available})`;
|
|
162
|
-
}
|
|
163
|
-
return null;
|
|
164
|
-
}
|
|
165
33
|
/**
|
|
166
34
|
* Returns true when a CEL expression path (from walkCelExpressions, e.g. "routes[0].inputs.q")
|
|
167
35
|
* falls within the scope of a context (e.g. "$.routes[*].inputs").
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/analyzer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Telo Analyzer - Static manifest validator for Telo manifests.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -41,13 +41,17 @@
|
|
|
41
41
|
"ajv-formats": "^3.0.1",
|
|
42
42
|
"jsonpath-plus": "^10.3.0",
|
|
43
43
|
"yaml": "^2.8.3",
|
|
44
|
-
"@telorun/sdk": "0.7.0"
|
|
44
|
+
"@telorun/sdk": "0.7.0",
|
|
45
|
+
"@telorun/templating": "0.2.0"
|
|
45
46
|
},
|
|
46
47
|
"devDependencies": {
|
|
47
48
|
"@types/node": "^20.0.0",
|
|
48
|
-
"typescript": "^5.0.0"
|
|
49
|
+
"typescript": "^5.0.0",
|
|
50
|
+
"vitest": "^2.1.8"
|
|
49
51
|
},
|
|
50
52
|
"scripts": {
|
|
51
|
-
"build": "tsc -p tsconfig.lib.json"
|
|
53
|
+
"build": "tsc -p tsconfig.lib.json",
|
|
54
|
+
"test": "vitest run",
|
|
55
|
+
"test:watch": "vitest"
|
|
52
56
|
}
|
|
53
57
|
}
|
package/src/analyzer.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
2
2
|
import type { Environment } from "@marcbachmann/cel-js";
|
|
3
|
+
import { defaultRegistry, walkCelExpressions } from "@telorun/templating";
|
|
3
4
|
import { AliasResolver } from "./alias-resolver.js";
|
|
4
5
|
import { AnalysisRegistry } from "./analysis-registry.js";
|
|
5
6
|
import {
|
|
@@ -21,19 +22,15 @@ import {
|
|
|
21
22
|
} from "./schema-compat.js";
|
|
22
23
|
import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisOptions } from "./types.js";
|
|
23
24
|
import {
|
|
24
|
-
extractAccessChains,
|
|
25
25
|
getManifestItem,
|
|
26
26
|
pathMatchesScope,
|
|
27
27
|
resolveContextAnnotations,
|
|
28
28
|
resolveTypeFieldToSchema,
|
|
29
|
-
validateChainAgainstSchema,
|
|
30
29
|
} from "./validate-cel-context.js";
|
|
31
30
|
import { validateExtends } from "./validate-extends.js";
|
|
32
31
|
import { validateReferences } from "./validate-references.js";
|
|
33
32
|
import { validateThrowsCoverage } from "./validate-throws-coverage.js";
|
|
34
33
|
|
|
35
|
-
const TEMPLATE_REGEX = /\$\{\{\s*([^}]+?)\s*\}\}/g;
|
|
36
|
-
|
|
37
34
|
const SELF_PREFIX = "Self.";
|
|
38
35
|
|
|
39
36
|
/** Resolve an alias-prefixed kind value (e.g. `Self.Encoder` or `Ai.Model`)
|
|
@@ -69,24 +66,6 @@ function lookupDefinitionTypeField(
|
|
|
69
66
|
return resolveTypeFieldToSchema(value, allManifests);
|
|
70
67
|
}
|
|
71
68
|
|
|
72
|
-
function walkCelExpressions(
|
|
73
|
-
value: unknown,
|
|
74
|
-
path: string,
|
|
75
|
-
cb: (expr: string, path: string) => void,
|
|
76
|
-
): void {
|
|
77
|
-
if (typeof value === "string") {
|
|
78
|
-
for (const m of value.matchAll(TEMPLATE_REGEX)) {
|
|
79
|
-
cb(m[1].trim(), path);
|
|
80
|
-
}
|
|
81
|
-
} else if (Array.isArray(value)) {
|
|
82
|
-
value.forEach((v, i) => walkCelExpressions(v, `${path}[${i}]`, cb));
|
|
83
|
-
} else if (value !== null && typeof value === "object") {
|
|
84
|
-
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
85
|
-
walkCelExpressions(v, path ? `${path}.${k}` : k, cb);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
69
|
const SOURCE = "telo-analyzer";
|
|
91
70
|
|
|
92
71
|
/**
|
|
@@ -651,31 +630,17 @@ export class StaticAnalyzer {
|
|
|
651
630
|
)
|
|
652
631
|
: undefined;
|
|
653
632
|
|
|
654
|
-
walkCelExpressions(m, "", (expr, path) => {
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
severity: DiagnosticSeverity.Error,
|
|
661
|
-
code: "CEL_SYNTAX_ERROR",
|
|
662
|
-
source: SOURCE,
|
|
663
|
-
message: `CEL syntax error at ${path}: ${e instanceof Error ? e.message : String(e)}`,
|
|
664
|
-
data: { resource, filePath, path },
|
|
665
|
-
});
|
|
666
|
-
return;
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
const accessChains = extractAccessChains(parsed.ast);
|
|
670
|
-
|
|
633
|
+
walkCelExpressions(m, "", (expr, path, engineName) => {
|
|
634
|
+
// Resolve the effective context for this expression's path. The
|
|
635
|
+
// engine receives a single closed schema and validates member-access
|
|
636
|
+
// chains against it; per-path resolution (step context, x-telo-context,
|
|
637
|
+
// kernel-globals merge) stays on the analyzer side because it depends
|
|
638
|
+
// on analyzer-internal state (definitions, aliases).
|
|
671
639
|
const contexts = mDefinition?.schema ? extractContextsFromSchema(mDefinition.schema) : [];
|
|
672
640
|
const invocationContext = (m.metadata as any)?.xTeloInvocationContext as
|
|
673
641
|
| Record<string, any>
|
|
674
642
|
| undefined;
|
|
675
643
|
|
|
676
|
-
// If no static context but we have step context, inject it
|
|
677
|
-
if (contexts.length === 0 && !invocationContext && !stepContextSchema) return;
|
|
678
|
-
|
|
679
644
|
let matchedContext: Record<string, any> | undefined;
|
|
680
645
|
let matchedScope: string | undefined;
|
|
681
646
|
for (const ctx of contexts) {
|
|
@@ -687,7 +652,6 @@ export class StaticAnalyzer {
|
|
|
687
652
|
}
|
|
688
653
|
if (!matchedContext) matchedContext = invocationContext;
|
|
689
654
|
|
|
690
|
-
// Merge step context into the effective context
|
|
691
655
|
if (stepContextSchema) {
|
|
692
656
|
const base = matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
|
|
693
657
|
matchedContext = {
|
|
@@ -699,28 +663,51 @@ export class StaticAnalyzer {
|
|
|
699
663
|
};
|
|
700
664
|
}
|
|
701
665
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
666
|
+
let effectiveContext: Record<string, any> | null = null;
|
|
667
|
+
if (matchedContext) {
|
|
668
|
+
const manifestItem = matchedScope
|
|
669
|
+
? getManifestItem(path, matchedScope, m as Record<string, any>)
|
|
670
|
+
: (m as Record<string, any>);
|
|
671
|
+
const resolvedContext = resolveContextAnnotations(
|
|
672
|
+
matchedContext,
|
|
673
|
+
manifestItem,
|
|
674
|
+
allManifests as Record<string, any>[],
|
|
675
|
+
);
|
|
676
|
+
effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const engine = defaultRegistry().get(engineName);
|
|
680
|
+
if (!engine) return;
|
|
681
|
+
const findings = engine.analyze(expr, { celEnv: this.celEnv, contextSchema: effectiveContext });
|
|
682
|
+
for (const f of findings) {
|
|
683
|
+
if (f.code === "CEL_SYNTAX_ERROR") {
|
|
684
|
+
diagnostics.push({
|
|
685
|
+
severity: DiagnosticSeverity.Error,
|
|
686
|
+
code: "CEL_SYNTAX_ERROR",
|
|
687
|
+
source: SOURCE,
|
|
688
|
+
message: `CEL syntax error at ${path}: ${f.message}`,
|
|
689
|
+
data: { resource, filePath, path },
|
|
690
|
+
});
|
|
691
|
+
} else if (f.code === "CEL_UNKNOWN_FIELD") {
|
|
692
|
+
diagnostics.push({
|
|
693
|
+
severity: DiagnosticSeverity.Error,
|
|
694
|
+
code: "CEL_UNKNOWN_FIELD",
|
|
695
|
+
source: SOURCE,
|
|
696
|
+
message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
|
|
697
|
+
data: { resource, filePath, path },
|
|
698
|
+
});
|
|
699
|
+
} else {
|
|
700
|
+
// Unknown code from a future engine — pass the message through,
|
|
701
|
+
// tagged with a generic ENGINE_DIAGNOSTIC code so downstream
|
|
702
|
+
// filters can still bucket it.
|
|
703
|
+
diagnostics.push({
|
|
704
|
+
severity: DiagnosticSeverity.Error,
|
|
705
|
+
code: f.code ?? "ENGINE_DIAGNOSTIC",
|
|
706
|
+
source: SOURCE,
|
|
707
|
+
message: `${m.kind}/${resource.name}: !${engineName} at '${path}': ${f.message}`,
|
|
708
|
+
data: { resource, filePath, path },
|
|
709
|
+
});
|
|
710
|
+
}
|
|
724
711
|
}
|
|
725
712
|
});
|
|
726
713
|
}
|
package/src/cel-environment.ts
CHANGED
|
@@ -1,54 +1,9 @@
|
|
|
1
1
|
import { Environment } from "@marcbachmann/cel-js";
|
|
2
|
-
import {
|
|
2
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
3
3
|
import { jsonSchemaToCelType } from "./schema-compat.js";
|
|
4
4
|
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
json: (value: unknown) => string;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const stub = (name: string) => () => {
|
|
11
|
-
throw new Error(
|
|
12
|
-
`${name}() is not available in this environment. ` +
|
|
13
|
-
`Construct StaticAnalyzer or Loader with celHandlers to enable it.`,
|
|
14
|
-
);
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
const STUB_HANDLERS: CelHandlers = {
|
|
18
|
-
sha256: stub("sha256"),
|
|
19
|
-
json: stub("json"),
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
/** Build a CEL `Environment` with Telo's stdlib of functions. Always registers the
|
|
23
|
-
* same function signatures (so `env.check()` succeeds for type-inference) — the
|
|
24
|
-
* handlers govern what the function does when called at runtime. Analyzer-only
|
|
25
|
-
* callers can omit handlers; runtime callers (kernel) must supply real ones.
|
|
26
|
-
*
|
|
27
|
-
* Also registers the `Stream` object type, backed by the `Stream` class from
|
|
28
|
-
* `@telorun/sdk`. CEL's type-checker rejects values whose constructor isn't
|
|
29
|
-
* Object/Map/Array/Set/registered; producers that need to expose an
|
|
30
|
-
* `AsyncIterable` through a stream-typed property must wrap the iterable in
|
|
31
|
-
* `new Stream(...)` so its constructor is the registered class. The type has
|
|
32
|
-
* no fields, so terminal access (passing the value through CEL) succeeds but
|
|
33
|
-
* member access raises a CEL error at runtime — matching the analyzer's
|
|
34
|
-
* static check on `x-telo-stream`-marked properties. */
|
|
35
|
-
export function buildCelEnvironment(handlers: CelHandlers = STUB_HANDLERS): Environment {
|
|
36
|
-
return new Environment({ unlistedVariablesAreDyn: true, enableOptionalTypes: true })
|
|
37
|
-
.registerFunction("join(list, string): string", (list: unknown[], sep: string) =>
|
|
38
|
-
list.map(String).join(sep),
|
|
39
|
-
)
|
|
40
|
-
.registerFunction("keys(map): list", (map: unknown) => {
|
|
41
|
-
if (map instanceof Map) return [...map.keys()];
|
|
42
|
-
return Object.keys(map as Record<string, unknown>);
|
|
43
|
-
})
|
|
44
|
-
.registerFunction("values(map): list", (map: unknown) => {
|
|
45
|
-
if (map instanceof Map) return [...map.values()];
|
|
46
|
-
return Object.values(map as Record<string, unknown>);
|
|
47
|
-
})
|
|
48
|
-
.registerFunction("sha256(string): string", (s: string) => handlers.sha256(s))
|
|
49
|
-
.registerFunction("json(dyn): string", (value: unknown) => handlers.json(value))
|
|
50
|
-
.registerType("Stream", Stream as unknown as new (...args: unknown[]) => unknown);
|
|
51
|
-
}
|
|
5
|
+
export { buildCelEnvironment } from "@telorun/templating";
|
|
6
|
+
export type { CelHandlers } from "@telorun/templating";
|
|
52
7
|
|
|
53
8
|
/** Clone `baseEnv` and register typed variable declarations so that
|
|
54
9
|
* `env.check(expr)` can infer return types for expressions referencing known variables.
|
package/src/manifest-loader.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Environment } from "@marcbachmann/cel-js";
|
|
2
2
|
import { isCompiledValue, type ResourceManifest } from "@telorun/sdk";
|
|
3
|
+
import { defaultCustomTags } from "@telorun/templating";
|
|
3
4
|
import { isMap, isPair, isScalar, isSeq, parseAllDocuments, type Document } from "yaml";
|
|
4
5
|
import { HttpSource } from "./sources/http-source.js";
|
|
5
6
|
import { RegistrySource } from "./sources/registry-source.js";
|
|
@@ -74,7 +75,7 @@ export class Loader {
|
|
|
74
75
|
return cloneManifestArray(cached.manifests);
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
const parsedDocuments = parseAllDocuments(text);
|
|
78
|
+
const parsedDocuments = parseAllDocuments(text, { customTags: defaultCustomTags() });
|
|
78
79
|
const rawDocs = parsedDocuments.map((d) => d.toJSON());
|
|
79
80
|
const offsets = documentLineOffsets(text);
|
|
80
81
|
const lineOffsets = buildLineOffsets(text);
|
|
@@ -194,7 +195,7 @@ export class Loader {
|
|
|
194
195
|
): Promise<ResourceManifest[]> {
|
|
195
196
|
const { text, source } = await this.pick(url).read(url);
|
|
196
197
|
|
|
197
|
-
const parsedDocuments = parseAllDocuments(text);
|
|
198
|
+
const parsedDocuments = parseAllDocuments(text, { customTags: defaultCustomTags() });
|
|
198
199
|
const rawDocs = parsedDocuments.map((d) => d.toJSON());
|
|
199
200
|
const offsets = documentLineOffsets(text);
|
|
200
201
|
const lineOffsets = buildLineOffsets(text);
|
package/src/precompile.ts
CHANGED
|
@@ -1,17 +1,43 @@
|
|
|
1
|
-
import type { CompiledValue } from "@telorun/sdk";
|
|
2
1
|
import type { Environment } from "@marcbachmann/cel-js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const EXACT_TEMPLATE_REGEX = /^\s*\$\{\{\s*([^}]+?)\s*\}\}\s*$/;
|
|
2
|
+
import { isCompiledValue } from "@telorun/sdk";
|
|
3
|
+
import { compileString, defaultRegistry, isTaggedSentinel } from "@telorun/templating";
|
|
6
4
|
|
|
7
5
|
/**
|
|
8
|
-
* Walks a raw YAML document and replaces all
|
|
9
|
-
* CompiledValue wrappers. Throws on CEL syntax
|
|
10
|
-
* Intended to be called once per document at load time.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
6
|
+
* Walks a raw YAML document and replaces all `${{ expr }}` strings (and
|
|
7
|
+
* `!cel`-tagged sentinels) with CompiledValue wrappers. Throws on CEL syntax
|
|
8
|
+
* errors. Intended to be called once per document at load time.
|
|
9
|
+
*
|
|
10
|
+
* Note on Telo.Definition / Telo.Abstract: the walker traverses these too.
|
|
11
|
+
* Their `schema` fields are JSON Schema metadata and don't typically contain
|
|
12
|
+
* `${{ }}` text, so compile is a no-op there. Their `template` fields, on
|
|
13
|
+
* the other hand, *do* carry CEL — Definition-driven templates are expanded
|
|
14
|
+
* by the kernel and rely on the precompiled tree. If a description or
|
|
15
|
+
* example string inside a schema happens to contain `${{ }}`, it will be
|
|
16
|
+
* interpreted as CEL; tag it `!literal` to opt out.
|
|
13
17
|
*/
|
|
14
18
|
export function precompileDoc(doc: unknown, env: Environment): unknown {
|
|
19
|
+
// Tagged sentinel: dispatch to the engine. The result is decorated with
|
|
20
|
+
// `__tagged` + `engine` + `source` when it's a CompiledValue so the
|
|
21
|
+
// analyzer's diagnostic walk can identify it on compiled trees too;
|
|
22
|
+
// engines returning plain values (e.g. `literal` → a string) pass through
|
|
23
|
+
// verbatim — the runtime contract is "any scalar value is fine."
|
|
24
|
+
if (isTaggedSentinel(doc)) {
|
|
25
|
+
const engine = defaultRegistry().get(doc.engine);
|
|
26
|
+
if (!engine) {
|
|
27
|
+
throw new Error(`Unknown templating engine: !${doc.engine}`);
|
|
28
|
+
}
|
|
29
|
+
const compiled = engine.compile(doc.source, { celEnv: env });
|
|
30
|
+
if (isCompiledValue(compiled)) {
|
|
31
|
+
return {
|
|
32
|
+
__tagged: true,
|
|
33
|
+
__compiled: true,
|
|
34
|
+
engine: doc.engine,
|
|
35
|
+
source: doc.source,
|
|
36
|
+
call: compiled.call.bind(compiled),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return compiled;
|
|
40
|
+
}
|
|
15
41
|
if (typeof doc === "string") return compileString(doc, env);
|
|
16
42
|
if (Array.isArray(doc)) return doc.map((item) => precompileDoc(item, env));
|
|
17
43
|
// Only recurse into plain objects. Class instances (ResourceInstance, ScopeHandle, etc.)
|
|
@@ -25,33 +51,3 @@ export function precompileDoc(doc: unknown, env: Environment): unknown {
|
|
|
25
51
|
}
|
|
26
52
|
return doc;
|
|
27
53
|
}
|
|
28
|
-
|
|
29
|
-
function compileString(s: string, env: Environment): unknown {
|
|
30
|
-
if (!s.includes("${{")) return s;
|
|
31
|
-
|
|
32
|
-
const exact = s.match(EXACT_TEMPLATE_REGEX);
|
|
33
|
-
if (exact) {
|
|
34
|
-
const expr = exact[1].trim();
|
|
35
|
-
const fn = env.parse(expr);
|
|
36
|
-
return { __compiled: true, source: expr, call: (ctx: Record<string, unknown>) => fn(ctx) } satisfies CompiledValue;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// Interpolated template — collect literal parts + compiled sub-expressions
|
|
40
|
-
const parts: Array<string | CompiledValue> = [];
|
|
41
|
-
let last = 0;
|
|
42
|
-
for (const m of s.matchAll(TEMPLATE_REGEX)) {
|
|
43
|
-
if (m.index! > last) parts.push(s.slice(last, m.index));
|
|
44
|
-
const expr = m[1].trim();
|
|
45
|
-
const fn = env.parse(expr);
|
|
46
|
-
parts.push({ __compiled: true, source: expr, call: (ctx: Record<string, unknown>) => fn(ctx) } satisfies CompiledValue);
|
|
47
|
-
last = m.index! + m[0].length;
|
|
48
|
-
}
|
|
49
|
-
if (last < s.length) parts.push(s.slice(last));
|
|
50
|
-
|
|
51
|
-
return {
|
|
52
|
-
__compiled: true,
|
|
53
|
-
source: s,
|
|
54
|
-
call: (ctx: Record<string, unknown>) =>
|
|
55
|
-
parts.map((p) => (typeof p === "string" ? p : String(p.call(ctx) ?? ""))).join(""),
|
|
56
|
-
} satisfies CompiledValue;
|
|
57
|
-
}
|
package/src/schema-compat.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import AjvModule from "ajv";
|
|
2
2
|
import addFormats from "ajv-formats";
|
|
3
|
+
import { isTaggedSentinel } from "@telorun/templating";
|
|
3
4
|
|
|
4
5
|
const Ajv = (AjvModule as any).default ?? AjvModule;
|
|
5
6
|
|
|
@@ -300,6 +301,9 @@ export function substituteCelFields(
|
|
|
300
301
|
if (typeof data === "string" && CEL_PURE_RE.test(data)) {
|
|
301
302
|
return celPlaceholderForSchema(resolved);
|
|
302
303
|
}
|
|
304
|
+
if (isTaggedSentinel(data)) {
|
|
305
|
+
return celPlaceholderForSchema(resolved);
|
|
306
|
+
}
|
|
303
307
|
if (Array.isArray(data)) {
|
|
304
308
|
const itemSchema = resolveRef((resolved.items ?? {}) as Record<string, any>, root);
|
|
305
309
|
return data.map((item) => substituteCelFields(item, itemSchema, root));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
export { extractAccessChains, validateChainAgainstSchema } from "@telorun/templating";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Resolve a type field value (string name, inline type, or raw schema) to a JSON Schema.
|
|
@@ -40,147 +40,6 @@ export function resolveTypeFieldToSchema(
|
|
|
40
40
|
return undefined;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
/**
|
|
44
|
-
* Extract all member-access chains from a CEL AST.
|
|
45
|
-
* Returns arrays like ["request", "query", "name"] for `request.query.name`.
|
|
46
|
-
* Chains that start with a call or non-identifier root are ignored.
|
|
47
|
-
* Bound variables in comprehension macros (filter, map, exists, all, exists_one) are excluded.
|
|
48
|
-
*/
|
|
49
|
-
export function extractAccessChains(node: ASTNode): string[][] {
|
|
50
|
-
const chains: string[][] = [];
|
|
51
|
-
visitNode(node, chains, new Set());
|
|
52
|
-
return chains;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// CEL comprehension macros that bind a variable: list.filter(x, ...), list.map(x, ...), etc.
|
|
56
|
-
const COMPREHENSION_METHODS = new Set(["filter", "map", "exists", "all", "exists_one"]);
|
|
57
|
-
|
|
58
|
-
function visitNode(node: ASTNode, chains: string[][], boundVars: Set<string>): void {
|
|
59
|
-
const chain = extractChain(node, boundVars);
|
|
60
|
-
if (chain !== null) {
|
|
61
|
-
chains.push(chain);
|
|
62
|
-
return; // don't recurse into parts of an already-collected chain
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Comprehension macros bind a variable in their body — handle them specially
|
|
66
|
-
// AST shape: { op: "rcall", args: [methodName, receiver, [boundVarId, body, ...]] }
|
|
67
|
-
if (
|
|
68
|
-
node.op === "rcall" &&
|
|
69
|
-
Array.isArray(node.args) &&
|
|
70
|
-
typeof node.args[0] === "string" &&
|
|
71
|
-
COMPREHENSION_METHODS.has(node.args[0])
|
|
72
|
-
) {
|
|
73
|
-
const receiver = node.args[1];
|
|
74
|
-
const comprehensionArgs = node.args[2];
|
|
75
|
-
if (isASTNode(receiver)) visitNode(receiver, chains, boundVars);
|
|
76
|
-
if (
|
|
77
|
-
Array.isArray(comprehensionArgs) &&
|
|
78
|
-
comprehensionArgs.length >= 2 &&
|
|
79
|
-
isASTNode(comprehensionArgs[0]) &&
|
|
80
|
-
(comprehensionArgs[0] as ASTNode).op === "id"
|
|
81
|
-
) {
|
|
82
|
-
const newBoundVars = new Set(boundVars);
|
|
83
|
-
newBoundVars.add((comprehensionArgs[0] as ASTNode).args as string);
|
|
84
|
-
for (let i = 1; i < comprehensionArgs.length; i++) {
|
|
85
|
-
const arg = comprehensionArgs[i];
|
|
86
|
-
if (isASTNode(arg)) visitNode(arg as ASTNode, chains, newBoundVars);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const args = node.args;
|
|
93
|
-
if (Array.isArray(args)) {
|
|
94
|
-
for (const arg of args) {
|
|
95
|
-
if (isASTNode(arg)) {
|
|
96
|
-
visitNode(arg, chains, boundVars);
|
|
97
|
-
} else if (Array.isArray(arg)) {
|
|
98
|
-
for (const item of arg) {
|
|
99
|
-
if (isASTNode(item)) visitNode(item, chains, boundVars);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function isASTNode(v: unknown): v is ASTNode {
|
|
107
|
-
return v !== null && typeof v === "object" && "op" in (v as object);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Sentinel used in extracted chains to represent an `[index]` access. The
|
|
111
|
-
// validator treats it like any other deeper segment, so a chain that reaches
|
|
112
|
-
// past an `x-telo-stream`-marked property via `[N]` is rejected the same way
|
|
113
|
-
// `.field` access is. The actual index expression is opaque to the chain
|
|
114
|
-
// validator (it only checks property names against the schema).
|
|
115
|
-
const INDEX_SEGMENT = "[*]";
|
|
116
|
-
|
|
117
|
-
/** Returns the member-access chain for a node if it is purely an id, ".", or
|
|
118
|
-
* "[]" chain; else null. Bracket index access is captured as `INDEX_SEGMENT`
|
|
119
|
-
* so it counts as a chain extension for the stream-property opacity rule. */
|
|
120
|
-
function extractChain(node: ASTNode, boundVars: Set<string>): string[] | null {
|
|
121
|
-
if (node.op === "id") {
|
|
122
|
-
const name = node.args as string;
|
|
123
|
-
if (boundVars.has(name)) return null; // bound by a comprehension macro, not a free access
|
|
124
|
-
return [name];
|
|
125
|
-
}
|
|
126
|
-
if (node.op === ".") {
|
|
127
|
-
const [obj, field] = node.args as [ASTNode, string];
|
|
128
|
-
const parent = extractChain(obj, boundVars);
|
|
129
|
-
if (parent !== null) return [...parent, field];
|
|
130
|
-
}
|
|
131
|
-
if (node.op === "[]") {
|
|
132
|
-
const [obj] = node.args as [ASTNode, ASTNode];
|
|
133
|
-
const parent = extractChain(obj, boundVars);
|
|
134
|
-
if (parent !== null) return [...parent, INDEX_SEGMENT];
|
|
135
|
-
}
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Check whether a member-access chain accesses only fields declared in a JSON Schema.
|
|
141
|
-
* Returns an error string if a field is unknown in a schema that declares explicit
|
|
142
|
-
* properties without `additionalProperties: true`, or if the chain attempts to
|
|
143
|
-
* reach inside an `x-telo-stream: true` property.
|
|
144
|
-
* Returns null when the chain is valid or the schema is too open to judge.
|
|
145
|
-
*/
|
|
146
|
-
export function validateChainAgainstSchema(
|
|
147
|
-
chain: string[],
|
|
148
|
-
schema: Record<string, any>,
|
|
149
|
-
): string | null {
|
|
150
|
-
let current: Record<string, any> = schema;
|
|
151
|
-
for (let i = 0; i < chain.length; i++) {
|
|
152
|
-
const key = chain[i]!;
|
|
153
|
-
if (!current || typeof current !== "object") return null;
|
|
154
|
-
const props: Record<string, any> | undefined = current.properties;
|
|
155
|
-
if (!props) return null;
|
|
156
|
-
if (key in props) {
|
|
157
|
-
const propSchema = props[key];
|
|
158
|
-
// Stream-typed properties are opaque: pass through by reference, no
|
|
159
|
-
// member access inside. Reaching `result.output` is fine; reaching
|
|
160
|
-
// `result.output.text` is a wiring mistake — the consumer either iterates
|
|
161
|
-
// the stream in a JS.Script step or pipes it through an Encoder.
|
|
162
|
-
if (
|
|
163
|
-
propSchema &&
|
|
164
|
-
typeof propSchema === "object" &&
|
|
165
|
-
propSchema["x-telo-stream"] === true &&
|
|
166
|
-
i < chain.length - 1
|
|
167
|
-
) {
|
|
168
|
-
const path = chain.slice(0, i + 1).join(".");
|
|
169
|
-
return `'${path}' yields a stream — pipe it through an Encoder or iterate in a JS.Script step (no member access on stream-typed values)`;
|
|
170
|
-
}
|
|
171
|
-
// Known property — drill into it even if additionalProperties is true
|
|
172
|
-
current = propSchema;
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
// Unknown property — only flag if schema is closed
|
|
176
|
-
if (current.additionalProperties === true) return null;
|
|
177
|
-
const path = chain.slice(0, i + 1).join(".");
|
|
178
|
-
const available = Object.keys(props).join(", ");
|
|
179
|
-
return `'${path}' is not defined (available: ${available})`;
|
|
180
|
-
}
|
|
181
|
-
return null;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
43
|
/**
|
|
185
44
|
* Returns true when a CEL expression path (from walkCelExpressions, e.g. "routes[0].inputs.q")
|
|
186
45
|
* falls within the scope of a context (e.g. "$.routes[*].inputs").
|