@telorun/analyzer 0.12.1 → 0.13.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/analysis-registry.d.ts +12 -0
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +15 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +64 -84
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +25 -0
- package/dist/cel-environment.d.ts +1 -1
- package/dist/cel-environment.d.ts.map +1 -1
- package/dist/cel-environment.js +40 -2
- package/dist/dependency-graph.d.ts.map +1 -1
- package/dist/dependency-graph.js +41 -62
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/kernel-globals.d.ts +1 -1
- package/dist/kernel-globals.d.ts.map +1 -1
- package/dist/kernel-globals.js +19 -1
- package/dist/manifest-visitor.d.ts +109 -0
- package/dist/manifest-visitor.d.ts.map +1 -0
- package/dist/manifest-visitor.js +110 -0
- package/dist/schema-compat.d.ts +10 -0
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +32 -0
- package/dist/validate-cel-context.d.ts +14 -0
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +38 -0
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +117 -160
- package/dist/validate-unused-declarations.d.ts +25 -0
- package/dist/validate-unused-declarations.d.ts.map +1 -0
- package/dist/validate-unused-declarations.js +91 -0
- package/package.json +2 -2
- package/src/analysis-registry.ts +20 -0
- package/src/analyzer.ts +157 -169
- package/src/builtins.ts +25 -0
- package/src/cel-environment.ts +42 -1
- package/src/dependency-graph.ts +37 -52
- package/src/index.ts +11 -0
- package/src/kernel-globals.ts +22 -1
- package/src/manifest-visitor.ts +251 -0
- package/src/schema-compat.ts +32 -0
- package/src/validate-cel-context.ts +50 -0
- package/src/validate-references.ts +168 -211
- package/src/validate-unused-declarations.ts +95 -0
package/src/analyzer.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
2
2
|
import type { Environment } from "@marcbachmann/cel-js";
|
|
3
|
-
import { defaultRegistry,
|
|
3
|
+
import { defaultRegistry, isTaggedSentinel } from "@telorun/templating";
|
|
4
4
|
import { AliasResolver } from "./alias-resolver.js";
|
|
5
5
|
import { AnalysisRegistry } from "./analysis-registry.js";
|
|
6
6
|
import {
|
|
@@ -12,6 +12,7 @@ import { DefinitionRegistry } from "./definition-registry.js";
|
|
|
12
12
|
import { buildDependencyGraph, formatCycle } from "./dependency-graph.js";
|
|
13
13
|
import { buildKernelGlobalsSchema, mergeKernelGlobalsIntoContext } from "./kernel-globals.js";
|
|
14
14
|
import { computeSuggestKind } from "./kind-suggest.js";
|
|
15
|
+
import { visitManifest } from "./manifest-visitor.js";
|
|
15
16
|
import { isModuleKind } from "./module-kinds.js";
|
|
16
17
|
import { normalizeInlineResources } from "./normalize-inline-resources.js";
|
|
17
18
|
import { REF_VALIDATION_SKIP_KINDS } from "./system-kinds.js";
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
} from "./schema-compat.js";
|
|
26
27
|
import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisOptions } from "./types.js";
|
|
27
28
|
import {
|
|
29
|
+
extractContextsFromSchema,
|
|
28
30
|
getManifestItem,
|
|
29
31
|
pathMatchesScope,
|
|
30
32
|
resolveContextAnnotations,
|
|
@@ -34,6 +36,7 @@ import { validateExtends } from "./validate-extends.js";
|
|
|
34
36
|
import { validateNestedInlineResources } from "./validate-nested-inline.js";
|
|
35
37
|
import { validateProviderCoherence } from "./validate-provider-coherence.js";
|
|
36
38
|
import { validateReferences } from "./validate-references.js";
|
|
39
|
+
import { validateUnusedDeclarations } from "./validate-unused-declarations.js";
|
|
37
40
|
import { validateThrowsCoverage } from "./validate-throws-coverage.js";
|
|
38
41
|
|
|
39
42
|
const SELF_PREFIX = "Self.";
|
|
@@ -187,56 +190,6 @@ function manifestRootForResolver(
|
|
|
187
190
|
};
|
|
188
191
|
}
|
|
189
192
|
|
|
190
|
-
/**
|
|
191
|
-
* Walk a JSON Schema tree and collect all `x-telo-context` annotations,
|
|
192
|
-
* returning them as `{ scope, schema }` pairs using JSONPath-style scopes —
|
|
193
|
-
* the same format the analyzer uses for CEL context validation.
|
|
194
|
-
*
|
|
195
|
-
* Result is sorted by scope specificity (longer scope first) so that the
|
|
196
|
-
* per-expression resolver's first-match-wins logic picks the most-specific
|
|
197
|
-
* context. Without this, a broader ancestor scope (e.g. `$.resources[*]`)
|
|
198
|
-
* could shadow a narrower descendant scope whose activation differs.
|
|
199
|
-
*/
|
|
200
|
-
function extractContextsFromSchema(
|
|
201
|
-
schema: Record<string, any>,
|
|
202
|
-
path = "$",
|
|
203
|
-
): Array<{ scope: string; schema: Record<string, any> }> {
|
|
204
|
-
const all = collectContexts(schema, path);
|
|
205
|
-
return all.sort((a, b) => b.scope.length - a.scope.length);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function collectContexts(
|
|
209
|
-
schema: Record<string, any>,
|
|
210
|
-
path: string,
|
|
211
|
-
): Array<{ scope: string; schema: Record<string, any> }> {
|
|
212
|
-
if (!schema || typeof schema !== "object") return [];
|
|
213
|
-
const results: Array<{ scope: string; schema: Record<string, any> }> = [];
|
|
214
|
-
|
|
215
|
-
if (schema["x-telo-context"]) {
|
|
216
|
-
results.push({ scope: path, schema: schema["x-telo-context"] });
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
if (schema.properties) {
|
|
220
|
-
for (const [key, value] of Object.entries(schema.properties as Record<string, any>)) {
|
|
221
|
-
results.push(...collectContexts(value, `${path}.${key}`));
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
if (schema.items && typeof schema.items === "object") {
|
|
226
|
-
results.push(...collectContexts(schema.items, `${path}[*]`));
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
for (const key of ["oneOf", "anyOf", "allOf"] as const) {
|
|
230
|
-
if (Array.isArray(schema[key])) {
|
|
231
|
-
for (const subschema of schema[key]) {
|
|
232
|
-
results.push(...collectContexts(subschema, path));
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
return results;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
193
|
/** Resolve a local `$ref` (only `#/$defs/<name>` form) against the root schema.
|
|
241
194
|
* Non-refs and unresolved refs pass through unchanged. */
|
|
242
195
|
function resolveLocalRef(
|
|
@@ -439,20 +392,31 @@ function collectCelTypeIssues(
|
|
|
439
392
|
manifest: ResourceManifest,
|
|
440
393
|
baseTypedEnv: Environment,
|
|
441
394
|
rootEnv: Environment,
|
|
395
|
+
rootModuleManifest?: ResourceManifest,
|
|
442
396
|
): SchemaIssue[] {
|
|
443
397
|
const issues: SchemaIssue[] = [];
|
|
444
398
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
399
|
+
// A pure CEL value type-checks the same regardless of surface form: a
|
|
400
|
+
// `${{ … }}` string and a `!cel`-tagged sentinel must behave identically.
|
|
401
|
+
let celExpr: string | undefined;
|
|
402
|
+
if (isTaggedSentinel(data)) {
|
|
403
|
+
// Non-CEL engines (e.g. `!literal`) are analyzed by their own engine pass.
|
|
404
|
+
if (data.engine !== "cel") return issues;
|
|
405
|
+
celExpr = data.source;
|
|
406
|
+
} else if (typeof data === "string" && CEL_PURE_RE.test(data)) {
|
|
407
|
+
celExpr = data.match(CEL_EXPR_RE)?.[1]?.trim();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (celExpr !== undefined) {
|
|
411
|
+
{
|
|
412
|
+
const expr = celExpr;
|
|
449
413
|
|
|
450
414
|
// Merge x-telo-context variables for this path if applicable
|
|
451
415
|
let typedEnv = baseTypedEnv;
|
|
452
416
|
if (definition.schema) {
|
|
453
417
|
for (const ctx of extractContextsFromSchema(definition.schema)) {
|
|
454
418
|
if (!pathMatchesScope(path, ctx.scope)) continue;
|
|
455
|
-
typedEnv = buildTypedCelEnvironment(rootEnv, manifest, ctx.schema);
|
|
419
|
+
typedEnv = buildTypedCelEnvironment(rootEnv, manifest, ctx.schema, rootModuleManifest);
|
|
456
420
|
break;
|
|
457
421
|
}
|
|
458
422
|
}
|
|
@@ -475,8 +439,9 @@ function collectCelTypeIssues(
|
|
|
475
439
|
} else if (checkResult?.valid && checkResult.type && schema) {
|
|
476
440
|
const celType = checkResult.type.split("<")[0]!;
|
|
477
441
|
if (!celTypeSatisfiesJsonSchema(celType, schema)) {
|
|
442
|
+
const expected = schema["x-telo-type"] ?? schema.type ?? "unknown";
|
|
478
443
|
issues.push({
|
|
479
|
-
message: `CEL returns '${checkResult.type}' but field expects '${
|
|
444
|
+
message: `CEL returns '${checkResult.type}' but field expects '${expected}'`,
|
|
480
445
|
path,
|
|
481
446
|
});
|
|
482
447
|
}
|
|
@@ -497,6 +462,7 @@ function collectCelTypeIssues(
|
|
|
497
462
|
manifest,
|
|
498
463
|
baseTypedEnv,
|
|
499
464
|
rootEnv,
|
|
465
|
+
rootModuleManifest,
|
|
500
466
|
),
|
|
501
467
|
);
|
|
502
468
|
}
|
|
@@ -512,6 +478,7 @@ function collectCelTypeIssues(
|
|
|
512
478
|
manifest,
|
|
513
479
|
baseTypedEnv,
|
|
514
480
|
rootEnv,
|
|
481
|
+
rootModuleManifest,
|
|
515
482
|
),
|
|
516
483
|
);
|
|
517
484
|
}
|
|
@@ -758,6 +725,13 @@ export class StaticAnalyzer {
|
|
|
758
725
|
// recognises variables, secrets, resources, env automatically
|
|
759
726
|
const kernelGlobals = buildKernelGlobalsSchema(allManifests);
|
|
760
727
|
|
|
728
|
+
// The module doc (Application/Library) carries the Application-only `ports`
|
|
729
|
+
// namespace; threaded into per-resource CEL typing so `${{ ports.X }}`
|
|
730
|
+
// resolves its nominal brand cross-doc.
|
|
731
|
+
const moduleManifest =
|
|
732
|
+
allManifests.find((mm) => mm.kind === "Telo.Application") ??
|
|
733
|
+
allManifests.find((mm) => mm.kind === "Telo.Library");
|
|
734
|
+
|
|
761
735
|
// Validate each non-definition, non-system resource
|
|
762
736
|
for (const m of allManifests) {
|
|
763
737
|
const filePath = (m.metadata as { source?: string } | undefined)?.source;
|
|
@@ -816,8 +790,17 @@ export class StaticAnalyzer {
|
|
|
816
790
|
}
|
|
817
791
|
: definition.schema;
|
|
818
792
|
// Phase 1: CEL type checking — walk data+schema together, check env.check() return types
|
|
819
|
-
const baseTypedEnv = buildTypedCelEnvironment(this.celEnv, m);
|
|
820
|
-
const celIssues = collectCelTypeIssues(
|
|
793
|
+
const baseTypedEnv = buildTypedCelEnvironment(this.celEnv, m, undefined, moduleManifest);
|
|
794
|
+
const celIssues = collectCelTypeIssues(
|
|
795
|
+
m,
|
|
796
|
+
schema,
|
|
797
|
+
"",
|
|
798
|
+
definition,
|
|
799
|
+
m,
|
|
800
|
+
baseTypedEnv,
|
|
801
|
+
this.celEnv,
|
|
802
|
+
moduleManifest,
|
|
803
|
+
);
|
|
821
804
|
// Phase 2+3: AJV on substituted data — CEL fields replaced with typed placeholders
|
|
822
805
|
const ajvIssues = validateAgainstSchema(substituteCelFields(m, schema), schema);
|
|
823
806
|
const issues = [...celIssues, ...ajvIssues];
|
|
@@ -959,125 +942,127 @@ export class StaticAnalyzer {
|
|
|
959
942
|
}
|
|
960
943
|
}
|
|
961
944
|
|
|
962
|
-
// Validate CEL syntax and context variable access in all manifests
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
945
|
+
// Validate CEL syntax and context variable access in all manifests. The
|
|
946
|
+
// walker discovers every compiled CEL node by scanning the value tree and
|
|
947
|
+
// hands back the `x-telo-context` schema matched at the enclosing path; the
|
|
948
|
+
// per-path resolution (step context, kernel-globals merge, x-telo-context-*
|
|
949
|
+
// annotation resolution) stays here because it depends on analyzer-internal
|
|
950
|
+
// state (definitions, aliases, the typed CEL env).
|
|
951
|
+
// Per-resource state computed at enter and read by that resource's CEL
|
|
952
|
+
// sites. The manifest / resource / filePath come straight off each CelSite's
|
|
953
|
+
// `source` (no need to capture them); only the derived step / invocation
|
|
954
|
+
// context — which require analyzer state to build — are stashed here.
|
|
955
|
+
let celStepContextSchema: Record<string, any> | undefined;
|
|
956
|
+
let celInvocationContext: Record<string, any> | undefined;
|
|
957
|
+
|
|
958
|
+
visitManifest(
|
|
959
|
+
allManifests,
|
|
960
|
+
defs,
|
|
961
|
+
{
|
|
962
|
+
onResourceEnter: (e) => {
|
|
963
|
+
const m = e.source;
|
|
964
|
+
celInvocationContext = (m.metadata as any)?.xTeloInvocationContext as
|
|
965
|
+
| Record<string, any>
|
|
966
|
+
| undefined;
|
|
967
|
+
celStepContextSchema = e.definition?.schema
|
|
968
|
+
? buildStepContextSchema(
|
|
969
|
+
m as Record<string, any>,
|
|
970
|
+
e.definition.schema as Record<string, any>,
|
|
971
|
+
allManifests as Record<string, any>[],
|
|
972
|
+
defs,
|
|
973
|
+
aliases,
|
|
974
|
+
)
|
|
975
|
+
: undefined;
|
|
976
|
+
},
|
|
977
|
+
onCel: (e) => {
|
|
978
|
+
const m = e.source;
|
|
979
|
+
const resource = { kind: m.kind, name: m.metadata?.name as string };
|
|
980
|
+
const filePath = (m.metadata as { source?: string } | undefined)?.source;
|
|
981
|
+
const { expr, path, engineName, matchedScope } = e;
|
|
982
|
+
|
|
983
|
+
let matchedContext: Record<string, any> | undefined =
|
|
984
|
+
e.contextSchema ?? celInvocationContext;
|
|
985
|
+
|
|
986
|
+
if (celStepContextSchema) {
|
|
987
|
+
const base =
|
|
988
|
+
matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
|
|
989
|
+
matchedContext = {
|
|
990
|
+
...base,
|
|
991
|
+
properties: {
|
|
992
|
+
...(base.properties ?? {}),
|
|
993
|
+
steps: celStepContextSchema,
|
|
994
|
+
},
|
|
995
|
+
};
|
|
1000
996
|
}
|
|
1001
|
-
}
|
|
1002
|
-
if (!matchedContext) matchedContext = invocationContext;
|
|
1003
|
-
|
|
1004
|
-
if (stepContextSchema) {
|
|
1005
|
-
const base = matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
|
|
1006
|
-
matchedContext = {
|
|
1007
|
-
...base,
|
|
1008
|
-
properties: {
|
|
1009
|
-
...(base.properties ?? {}),
|
|
1010
|
-
steps: stepContextSchema,
|
|
1011
|
-
},
|
|
1012
|
-
};
|
|
1013
|
-
}
|
|
1014
997
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
});
|
|
1032
|
-
effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
const engine = defaultRegistry().get(engineName);
|
|
1036
|
-
if (!engine) {
|
|
1037
|
-
// No registered engine owns this tag — the expression would go
|
|
1038
|
-
// entirely unanalyzed. Surface it rather than skipping silently.
|
|
1039
|
-
diagnostics.push({
|
|
1040
|
-
severity: DiagnosticSeverity.Error,
|
|
1041
|
-
code: "UNKNOWN_ENGINE",
|
|
1042
|
-
source: SOURCE,
|
|
1043
|
-
message: `${m.kind}/${resource.name}: no templating engine registered for '!${engineName}' at '${path}'.`,
|
|
1044
|
-
data: { resource, filePath, path },
|
|
1045
|
-
});
|
|
1046
|
-
return;
|
|
1047
|
-
}
|
|
1048
|
-
const findings = engine.analyze(expr, { celEnv: this.celEnv, contextSchema: effectiveContext });
|
|
1049
|
-
for (const f of findings) {
|
|
1050
|
-
if (f.code === "CEL_SYNTAX_ERROR") {
|
|
1051
|
-
diagnostics.push({
|
|
1052
|
-
severity: DiagnosticSeverity.Error,
|
|
1053
|
-
code: "CEL_SYNTAX_ERROR",
|
|
1054
|
-
source: SOURCE,
|
|
1055
|
-
message: `CEL syntax error at ${path}: ${f.message}`,
|
|
1056
|
-
data: { resource, filePath, path },
|
|
1057
|
-
});
|
|
1058
|
-
} else if (f.code === "CEL_UNKNOWN_FIELD") {
|
|
1059
|
-
diagnostics.push({
|
|
1060
|
-
severity: DiagnosticSeverity.Error,
|
|
1061
|
-
code: "CEL_UNKNOWN_FIELD",
|
|
1062
|
-
source: SOURCE,
|
|
1063
|
-
message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
|
|
1064
|
-
data: { resource, filePath, path },
|
|
998
|
+
let effectiveContext: Record<string, any> | null = null;
|
|
999
|
+
if (matchedContext) {
|
|
1000
|
+
const manifestItem = matchedScope
|
|
1001
|
+
? getManifestItem(path, matchedScope, m as Record<string, any>)
|
|
1002
|
+
: (m as Record<string, any>);
|
|
1003
|
+
const rootForResolver = manifestRootForResolver(
|
|
1004
|
+
m as Record<string, any>,
|
|
1005
|
+
defs,
|
|
1006
|
+
aliases,
|
|
1007
|
+
allManifests as Record<string, any>[],
|
|
1008
|
+
);
|
|
1009
|
+
const resolvedContext = resolveContextAnnotations(matchedContext, manifestItem, {
|
|
1010
|
+
manifestRoot: rootForResolver,
|
|
1011
|
+
defs,
|
|
1012
|
+
aliases,
|
|
1013
|
+
allManifests: allManifests as Record<string, any>[],
|
|
1065
1014
|
});
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1015
|
+
effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const engine = defaultRegistry().get(engineName);
|
|
1019
|
+
if (!engine) {
|
|
1020
|
+
// No registered engine owns this tag — the expression would go
|
|
1021
|
+
// entirely unanalyzed. Surface it rather than skipping silently.
|
|
1070
1022
|
diagnostics.push({
|
|
1071
1023
|
severity: DiagnosticSeverity.Error,
|
|
1072
|
-
code:
|
|
1024
|
+
code: "UNKNOWN_ENGINE",
|
|
1073
1025
|
source: SOURCE,
|
|
1074
|
-
message: `${m.kind}/${resource.name}: !${engineName} at '${path}'
|
|
1026
|
+
message: `${m.kind}/${resource.name}: no templating engine registered for '!${engineName}' at '${path}'.`,
|
|
1075
1027
|
data: { resource, filePath, path },
|
|
1076
1028
|
});
|
|
1029
|
+
return;
|
|
1077
1030
|
}
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1031
|
+
const findings = engine.analyze(expr, { celEnv: this.celEnv, contextSchema: effectiveContext });
|
|
1032
|
+
for (const f of findings) {
|
|
1033
|
+
if (f.code === "CEL_SYNTAX_ERROR") {
|
|
1034
|
+
diagnostics.push({
|
|
1035
|
+
severity: DiagnosticSeverity.Error,
|
|
1036
|
+
code: "CEL_SYNTAX_ERROR",
|
|
1037
|
+
source: SOURCE,
|
|
1038
|
+
message: `CEL syntax error at ${path}: ${f.message}`,
|
|
1039
|
+
data: { resource, filePath, path },
|
|
1040
|
+
});
|
|
1041
|
+
} else if (f.code === "CEL_UNKNOWN_FIELD") {
|
|
1042
|
+
diagnostics.push({
|
|
1043
|
+
severity: DiagnosticSeverity.Error,
|
|
1044
|
+
code: "CEL_UNKNOWN_FIELD",
|
|
1045
|
+
source: SOURCE,
|
|
1046
|
+
message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
|
|
1047
|
+
data: { resource, filePath, path },
|
|
1048
|
+
});
|
|
1049
|
+
} else {
|
|
1050
|
+
// Unknown code from a future engine — pass the message through,
|
|
1051
|
+
// tagged with a generic ENGINE_DIAGNOSTIC code so downstream
|
|
1052
|
+
// filters can still bucket it.
|
|
1053
|
+
diagnostics.push({
|
|
1054
|
+
severity: DiagnosticSeverity.Error,
|
|
1055
|
+
code: f.code ?? "ENGINE_DIAGNOSTIC",
|
|
1056
|
+
source: SOURCE,
|
|
1057
|
+
message: `${m.kind}/${resource.name}: !${engineName} at '${path}': ${f.message}`,
|
|
1058
|
+
data: { resource, filePath, path },
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
},
|
|
1063
|
+
},
|
|
1064
|
+
{ aliases },
|
|
1065
|
+
);
|
|
1081
1066
|
|
|
1082
1067
|
// Validate resource references (Phase 3)
|
|
1083
1068
|
diagnostics.push(
|
|
@@ -1093,6 +1078,9 @@ export class StaticAnalyzer {
|
|
|
1093
1078
|
// Validate throws: declarations and catches: coverage (rules 1, 2, 4, 7)
|
|
1094
1079
|
diagnostics.push(...validateThrowsCoverage(allManifests, defs, aliases, this.celEnv));
|
|
1095
1080
|
|
|
1081
|
+
// Warn about declared variables / secrets / ports that no CEL references.
|
|
1082
|
+
diagnostics.push(...validateUnusedDeclarations(allManifests, this.celEnv));
|
|
1083
|
+
|
|
1096
1084
|
// Reroute diagnostics on synthetic (inline-extracted) resources back to
|
|
1097
1085
|
// the chain root so position-index lookups land on the parent doc.
|
|
1098
1086
|
return rewriteSyntheticOrigins(diagnostics, allManifests);
|
package/src/builtins.ts
CHANGED
|
@@ -279,6 +279,31 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
|
|
|
279
279
|
},
|
|
280
280
|
},
|
|
281
281
|
},
|
|
282
|
+
// Inbound ports the Application listens on. A name-keyed map mirroring
|
|
283
|
+
// `variables`: each entry binds a host env var (`env:`) that supplies a
|
|
284
|
+
// port integer (implicitly typed `integer`, 1–65535), with an optional
|
|
285
|
+
// `default:` used when the env var is unset. `protocol:` (default `tcp`)
|
|
286
|
+
// selects the transport — the runner reads this list to know the
|
|
287
|
+
// exposed ports before launch, and the analyzer brands the resolved
|
|
288
|
+
// `ports.<name>` value (tcp → TcpPort, udp → UdpPort) for static wiring
|
|
289
|
+
// checks. Application-only. See kernel/nodejs/src/application-env.ts.
|
|
290
|
+
ports: {
|
|
291
|
+
type: "object",
|
|
292
|
+
additionalProperties: {
|
|
293
|
+
type: "object",
|
|
294
|
+
required: ["env"],
|
|
295
|
+
properties: {
|
|
296
|
+
env: { type: "string" },
|
|
297
|
+
protocol: {
|
|
298
|
+
type: "string",
|
|
299
|
+
enum: ["tcp", "udp"],
|
|
300
|
+
default: "tcp",
|
|
301
|
+
},
|
|
302
|
+
default: { type: "integer", minimum: 1, maximum: 65535 },
|
|
303
|
+
},
|
|
304
|
+
additionalProperties: false,
|
|
305
|
+
},
|
|
306
|
+
},
|
|
282
307
|
},
|
|
283
308
|
required: ["metadata"],
|
|
284
309
|
additionalProperties: false,
|
package/src/cel-environment.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { Environment } from "@marcbachmann/cel-js";
|
|
2
2
|
import type { ResourceManifest } from "@telorun/sdk";
|
|
3
|
-
import { jsonSchemaToCelType } from "./schema-compat.js";
|
|
3
|
+
import { jsonSchemaToCelType, VALUE_BRAND_BASE } from "./schema-compat.js";
|
|
4
|
+
|
|
5
|
+
/** Transport protocol on a `ports` entry → the nominal CEL brand its resolved
|
|
6
|
+
* value carries. Mirrors the `protocol` enum in the Application schema. */
|
|
7
|
+
const PORT_PROTOCOL_BRAND: Record<string, string> = {
|
|
8
|
+
tcp: "TcpPort",
|
|
9
|
+
udp: "UdpPort",
|
|
10
|
+
};
|
|
4
11
|
|
|
5
12
|
export { buildCelEnvironment } from "@telorun/templating";
|
|
6
13
|
export type { CelHandlers } from "@telorun/templating";
|
|
@@ -19,10 +26,23 @@ export function buildTypedCelEnvironment(
|
|
|
19
26
|
baseEnv: Environment,
|
|
20
27
|
manifest: ResourceManifest,
|
|
21
28
|
extraContextSchema?: Record<string, any> | null,
|
|
29
|
+
// The `ports` namespace is Application-only and lives on the module doc, not
|
|
30
|
+
// on the resource being analyzed. When validating a resource, the caller
|
|
31
|
+
// passes the module manifest here so `${{ ports.X }}` types cross-doc.
|
|
32
|
+
rootModuleManifest?: ResourceManifest,
|
|
22
33
|
): Environment {
|
|
23
34
|
try {
|
|
24
35
|
const env = baseEnv.clone();
|
|
25
36
|
|
|
37
|
+
// Register nominal value brands (TcpPort/UdpPort/…) on the *clone* so the
|
|
38
|
+
// type-checker can distinguish structurally-identical values. The base env
|
|
39
|
+
// (shared with the kernel runtime) is untouched — a branded value flows as
|
|
40
|
+
// a plain integer at runtime, so only static checking needs these. cel-js
|
|
41
|
+
// auto-generates a field-less wrapper class; no runtime constructor needed.
|
|
42
|
+
for (const brand of Object.keys(VALUE_BRAND_BASE)) {
|
|
43
|
+
(env as any).registerType(brand, { fields: {} });
|
|
44
|
+
}
|
|
45
|
+
|
|
26
46
|
// Build typed ObjectSchema from manifest.variables if it looks like a schema map
|
|
27
47
|
const vars = (manifest as Record<string, unknown>).variables;
|
|
28
48
|
if (vars !== null && typeof vars === "object" && !Array.isArray(vars)) {
|
|
@@ -42,6 +62,27 @@ export function buildTypedCelEnvironment(
|
|
|
42
62
|
env.registerVariable("variables", "map");
|
|
43
63
|
}
|
|
44
64
|
|
|
65
|
+
// `ports` namespace: each entry types as the brand its `protocol` selects
|
|
66
|
+
// (tcp → TcpPort, udp → UdpPort), so `${{ ports.http }}` carries a nominal
|
|
67
|
+
// type that consuming fields can check against.
|
|
68
|
+
const portsManifest = ((rootModuleManifest ?? manifest) as Record<string, unknown>).ports;
|
|
69
|
+
if (portsManifest !== null && typeof portsManifest === "object" && !Array.isArray(portsManifest)) {
|
|
70
|
+
const portEntries = Object.entries(portsManifest as Record<string, any>).filter(
|
|
71
|
+
([, v]) => v !== null && typeof v === "object" && !Array.isArray(v),
|
|
72
|
+
);
|
|
73
|
+
if (portEntries.length > 0) {
|
|
74
|
+
const schema: Record<string, string> = {};
|
|
75
|
+
for (const [k, v] of portEntries) {
|
|
76
|
+
schema[k] = PORT_PROTOCOL_BRAND[(v as { protocol?: string }).protocol ?? "tcp"] ?? "int";
|
|
77
|
+
}
|
|
78
|
+
(env as any).registerVariable({ name: "ports", schema });
|
|
79
|
+
} else {
|
|
80
|
+
env.registerVariable("ports", "map");
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
env.registerVariable("ports", "map");
|
|
84
|
+
}
|
|
85
|
+
|
|
45
86
|
env.registerVariable("secrets", "map");
|
|
46
87
|
env.registerVariable("resources", "map");
|
|
47
88
|
env.registerVariable("env", "map");
|
package/src/dependency-graph.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { ResourceManifest } from "@telorun/sdk";
|
|
|
2
2
|
import { isRefSentinel } from "@telorun/templating";
|
|
3
3
|
import type { AliasResolver } from "./alias-resolver.js";
|
|
4
4
|
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
5
|
-
import {
|
|
5
|
+
import { visitManifest } from "./manifest-visitor.js";
|
|
6
6
|
import { DEPENDENCY_GRAPH_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
|
|
7
7
|
|
|
8
8
|
export interface ResourceNode {
|
|
@@ -57,64 +57,49 @@ export function buildDependencyGraph(
|
|
|
57
57
|
const deps = new Map<string, Set<string>>();
|
|
58
58
|
for (const key of nodes.keys()) deps.set(key, new Set());
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
for (const [fieldPath, entry] of fieldMap) {
|
|
88
|
-
if (!isRefEntry(entry)) continue;
|
|
89
|
-
|
|
90
|
-
for (const val of resolveFieldValues(r, fieldPath)) {
|
|
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.
|
|
60
|
+
// Names of resources declared inside the *current* resource's scope fields —
|
|
61
|
+
// initialized on-demand at runtime, not at boot, so edges pointing to them
|
|
62
|
+
// are excluded. Scoping is per-source-resource: an edge A → B is dropped only
|
|
63
|
+
// when B is declared inside A's own scope (the visitor's ScopeBoundary fires
|
|
64
|
+
// before that resource's RefSites, so this is set before any edge is added).
|
|
65
|
+
let scopedNames = new Set<string>();
|
|
66
|
+
|
|
67
|
+
// Expanded map so refs nested behind x-telo-schema-from contribute edges to
|
|
68
|
+
// the DAG. Without these, a parent (e.g. Http.Server) can init before its
|
|
69
|
+
// extracted encoder and Phase 5 injection fires against a not-yet-created
|
|
70
|
+
// dependency.
|
|
71
|
+
visitManifest(
|
|
72
|
+
resources,
|
|
73
|
+
registry,
|
|
74
|
+
{
|
|
75
|
+
onScope: (e) => {
|
|
76
|
+
scopedNames = e.enclosedNames;
|
|
77
|
+
},
|
|
78
|
+
onRef: (e) => {
|
|
79
|
+
const sourceKey = nodeKey(e.source.kind, e.source.metadata!.name as string);
|
|
80
|
+
const val = e.value;
|
|
81
|
+
|
|
82
|
+
// `!ref <name>` sentinel — look up the target's kind from the name
|
|
83
|
+
// (resources are unique by name) so the edge carries the concrete kind,
|
|
84
|
+
// matching the {kind, name} edge shape below.
|
|
96
85
|
if (isRefSentinel(val)) {
|
|
97
86
|
const refName = val.source;
|
|
98
|
-
if (scopedNames.has(refName))
|
|
87
|
+
if (scopedNames.has(refName)) return;
|
|
99
88
|
const node = nodesByName.get(refName);
|
|
100
|
-
if (node)
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
continue;
|
|
89
|
+
if (node) deps.get(sourceKey)!.add(nodeKey(node.kind, node.name));
|
|
90
|
+
return;
|
|
104
91
|
}
|
|
105
92
|
|
|
106
|
-
if (typeof val !== "object")
|
|
93
|
+
if (typeof val !== "object") return;
|
|
107
94
|
const ref = val as Record<string, unknown>;
|
|
108
|
-
if (!ref.kind || !ref.name)
|
|
109
|
-
|
|
110
|
-
if (scopedNames.has(ref.name as string)) continue;
|
|
95
|
+
if (!ref.kind || !ref.name) return;
|
|
96
|
+
if (scopedNames.has(ref.name as string)) return;
|
|
111
97
|
const targetKey = nodeKey(ref.kind as string, ref.name as string);
|
|
112
|
-
if (nodes.has(targetKey))
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
98
|
+
if (nodes.has(targetKey)) deps.get(sourceKey)!.add(targetKey);
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{ aliases, aliasesByModule, skipKinds: SYSTEM_KINDS, expand: true },
|
|
102
|
+
);
|
|
118
103
|
|
|
119
104
|
// --- Kahn's topological sort ---
|
|
120
105
|
// in-degree[X] = number of X's dependencies (size of deps[X])
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,17 @@ export type {
|
|
|
9
9
|
ParseError,
|
|
10
10
|
} from "./loaded-types.js";
|
|
11
11
|
export { flattenForAnalyzer, flattenLoadedModule } from "./flatten-for-analyzer.js";
|
|
12
|
+
export { visitManifest } from "./manifest-visitor.js";
|
|
13
|
+
export type {
|
|
14
|
+
CelSiteEvent,
|
|
15
|
+
ManifestVisitor,
|
|
16
|
+
RefSiteEvent,
|
|
17
|
+
ResourceEnterEvent,
|
|
18
|
+
ResourceExitEvent,
|
|
19
|
+
ScopeBoundaryEvent,
|
|
20
|
+
SchemaFromSiteEvent,
|
|
21
|
+
VisitOptions,
|
|
22
|
+
} from "./manifest-visitor.js";
|
|
12
23
|
export { Loader } from "./manifest-loader.js";
|
|
13
24
|
export { isModuleKind, MODULE_KINDS } from "./module-kinds.js";
|
|
14
25
|
export type { ModuleKind } from "./module-kinds.js";
|